diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f6f650c..c795e76 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -214,7 +214,7 @@ jobs: retention-days: 1 # Minimum. path: | addons - !addons/godot-xterm + !addons/godot_xterm html5_export: @@ -286,7 +286,7 @@ jobs: uses: actions/download-artifact@v2 with: name: plugins - path: ./ + path: ./addons - name: Run tests if: ${{ matrix.godot_version != 'v3.2-stable' }} shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 974521c..28d3c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] [Unreleased]: https://github.com/lihop/godot-xterm/compare/v2.0.0...HEAD +### Added +- Basic readline utility that provides line editing capabilities similar to GNU Readline. + ### Removed - TerminalSettings resource. Not currently used, and was showing up in every 'new resource' menu that is displayed after clicking on an empty Resource slot. diff --git a/addons/godot_xterm/util/readline.gd b/addons/godot_xterm/util/readline.gd new file mode 100644 index 0000000..fba60d7 --- /dev/null +++ b/addons/godot_xterm/util/readline.gd @@ -0,0 +1,108 @@ +# Copyright (c) 2021, Leroy Hopson (MIT License). +tool +extends Reference + +const KeyCodes = { + ESCAPE = "\u001b", + ENTER = "\r", + BACKSPACE = "\u0008", + UP_ARROW = "\u001b[A", + DOWN_ARROW = "\u001b[B", + LEFT_ARROW = "\u001b[D", + RIGHT_ARROW = "\u001b[C", +} + +var terminal +var _prompt: String +var _cursor_pos_min: int +var _cursor_pos: int +var _line: String + + +func _init(p_terminal) -> void: + terminal = p_terminal + assert(terminal.has_method("write")) + assert(terminal.has_signal("key_pressed")) + assert("cols" in terminal) + assert("rows" in terminal) + + +func readline(prompt := "> ") -> String: + _prompt = prompt + _cursor_pos_min = prompt.length() + _cursor_pos = prompt.length() + + terminal.write(_prompt) + + var input = yield(terminal, "key_pressed") + while input[1].scancode != KEY_ENTER: + print(input[0]) + match input[0]: + KeyCodes.BACKSPACE: + _backspace() + KeyCodes.UP_ARROW: + # TODO: History prev. + pass + KeyCodes.DOWN_ARROW: + # TODO: History next. + pass + KeyCodes.LEFT_ARROW, KeyCodes.RIGHT_ARROW: + # TODO: Implement Me! + pass + _: + terminal.write(input[0]) + _line += input[0] + _cursor_pos += 1 + if _cursor_pos > 0 and _cursor_pos % int(terminal.cols) == 0: + terminal.write("\u001bE") + input = yield(terminal, "key_pressed") + + return _line + + +func _backspace() -> void: + if _cursor_pos > _cursor_pos_min: + if _cursor_pos % int(terminal.cols) == 0: + terminal.write("\u001b[1A\u001b[%dC\u001b[K" % terminal.cols) + else: + terminal.write("\b \b") + _line = _line.substr(0, _cursor_pos - _cursor_pos_min - 1) + _cursor_pos -= 1 + + +func _refresh_line() -> void: + var num_rows := ceil(_cursor_pos / terminal.cols) + for _row in range(num_rows): + terminal.write("\r\u001b[2K\u001b[1A") + terminal.write("\r\u001b[1B%s%s" % [_prompt, _line]) + _cursor_pos = _prompt.length() + _cursor_pos += _line.length() + +## TODO +#func _add_history(line: String) -> void: +# _history.append(line) +# +# +## TODO +#func _load_history(filepath) -> int: +# var file := File.new() +# var err := file.open(filepath, File.READ) +# if err == OK: +# var line := file.get_line() +# while line != "": +# _history.append(line) +# line = file.get_line() +# file.close() +# return err +# +# +## TODO +#func _save_history(filepath) -> int: +# var file := File.new() +# var err := file.open(filepath, File.WRITE) +# if err == OK: +# for line in _history: +# assert(line is String) +# file.store_line(line) +# file.close() +# return err diff --git a/examples/readline/readline.gd b/examples/readline/readline.gd new file mode 100644 index 0000000..bdeaa8b --- /dev/null +++ b/examples/readline/readline.gd @@ -0,0 +1,12 @@ +extends "res://addons/godot_xterm/terminal.gd" + +const Readline = preload("res://addons/godot_xterm/util/readline.gd") + +var rl: Readline + + +func _ready(): + rl = Readline.new(self) + while true: + var line: String = yield(rl.readline("Enter something (anything): "), "completed") + write("\r\nYou entered: %s\r\n" % line) diff --git a/examples/readline/readline.tscn b/examples/readline/readline.tscn new file mode 100644 index 0000000..31b68bf --- /dev/null +++ b/examples/readline/readline.tscn @@ -0,0 +1,13 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://examples/readline/readline.gd" type="Script" id=1] + +[node name="Terminal" type="Control"] +anchor_right = 1.0 +anchor_bottom = 1.0 +rect_pivot_offset = Vector2( -194.759, 935.803 ) +focus_mode = 2 +script = ExtResource( 1 ) +__meta__ = { +"_edit_use_anchors_": false +} diff --git a/test/unit/readline.test.gd b/test/unit/readline.test.gd new file mode 100644 index 0000000..058dff5 --- /dev/null +++ b/test/unit/readline.test.gd @@ -0,0 +1,196 @@ +extends "res://addons/gut/test.gd" + + +class TestReadline: + extends "res://addons/gut/test.gd" + const Readline = preload("res://addons/godot_xterm/util/readline.gd") + const Terminal = preload("res://addons/godot_xterm/terminal.gd") + + var rl: Readline + var terminal: Terminal + var thread: Thread + + func before_each(): + terminal = Terminal.new() + add_child_autoqfree(terminal) + rl = Readline.new(terminal) + thread = Thread.new() + + func after_each(): + if thread.is_active(): + thread.wait_to_finish() + + func press_keys(keys): + yield(get_tree(), "idle_frame") + for key in keys: + terminal.call_deferred("emit_signal", "key_pressed", key[0], key[1]) + + func key(string: String) -> Array: + var event = InputEventKey.new() + + if string.length() == 1: + event.unicode = ord(string) + + match string: + "\r": + event.scancode = KEY_ENTER + "\b": + event.scancode = KEY_BACKSPACE + + return [string, event] + + +class TestBasic: + extends TestReadline + + func test_immediate_return(): + thread.start(self, "press_keys", [key("\r")]) + var line = yield(rl.readline("hello> "), "completed") + assert_eq(line, "") + + func test_basic_input(): + thread.start( + self, + "press_keys", + [ + key("t"), + key("e"), + key("s"), + key("t"), + key("\r"), + ] + ) + var line = yield(rl.readline("test> "), "completed") + assert_eq(line, "test") + + func test_basic_input_no_prompt(): + thread.start( + self, + "press_keys", + [ + key("o"), + key("k"), + key("\r"), + ] + ) + var line = yield(rl.readline(), "completed") + assert_eq(line, "ok") + + func test_basic_input_empty_prompt(): + thread.start( + self, + "press_keys", + [ + key("h"), + key("i"), + key("\r"), + ] + ) + var line = yield(rl.readline(""), "completed") + assert_eq(line, "hi") + + func test_backspace_in_line(): + thread.start( + self, + "press_keys", + [ + key("a"), + key("b"), + key("c"), + key("\b"), + key("d"), + key("\r"), + ] + ) + var line = yield(rl.readline("> "), "completed") + assert_eq(line, "abd") + + func test_backspace_to_prompt(): + thread.start( + self, + "press_keys", + [ + key("a"), + key("\b"), + key("\b"), + key("\b"), + key("\b"), + key("b"), + key("\r"), + ] + ) + var line = yield(rl.readline("aprompt> "), "completed") + var buffer = terminal.copy_all() + assert_eq(buffer.strip_edges(false, true), "aprompt>") + assert_eq(line, "b") + + func test_multi_line(): + var keys = [] + for _i in range(terminal.cols * 3): + keys.append(key("a")) + keys.append(key("\r")) + + thread.start(self, "press_keys", keys) + var line = yield(rl.readline("> "), "completed") + assert_eq(line, "a".repeat(terminal.cols * 3)) + + func test_backspace_multi_line(): + var keys = [] + for _i in range(terminal.cols): + keys.append(key("a")) + for _j in range(terminal.cols): + keys.append(key("b")) + for _k in range(terminal.cols + 5): + keys.append(key("\b")) + keys.append(key("f")) + keys.append(key("\r")) + + thread.start(self, "press_keys", keys) + var line = yield(rl.readline("> "), "completed") + assert_eq(line, "a".repeat(terminal.cols - 5) + "f") + var buffer = terminal.copy_all() + assert(buffer.strip_edges(false, true), "> " + "a".repeat(terminal.cols - 5) + "f") + +#class TestHistory: +# extends TestReadline +# +# func test_add_history(): +# rl.history = ["1", "2", "3"] +# rl.add_history("New line") +# assert_eq(rl.history, ["1", "2", "3", "New line"]) +# +# func test_add_history_max_len(): +# rl.history = ["1", "2", "3"] +# rl.history_max_len = 3 +# rl.add_history("New line") +# assert_eq(rl.history, ["2", "3", "New line"]) +# +# func test_add_history_0_max_len(): +# rl.history = [] +# rl.history_max_len = 0 +# rl.add_history("New line") +# assert_eq(rl.history, []) +# +# func test_equal_max_len(): +# rl.history = ["1", "2", "3"] +# rl.history_max_len = 3 +# assert_eq(rl.history, ["1", "2", "3"]) +# +# func test_larger_max_len(): +# rl.history = ["1", "2", "3"] +# rl.history_max_len = 4 +# assert_eq(rl.history, ["1", "2", "3"]) +# +# func test_smaller_max_len(): +# rl.history = ["1", "2", "3"] +# rl.history_max_len = 2 +# assert_eq(rl.history, ["2", "3"]) +# rl.history = ["1", "2", "3"] +# rl.history_max_len = 1 +# assert_eq(rl.history, ["3"]) +# rl.history = ["1", "2", "3"] +# rl.history_max_len = 0 +# assert_eq(rl.history, []) +# rl.history = ["1"] +# rl.history_max_len = 0 +# assert_eq(rl.history, [])