diff --git a/.gitignore b/.gitignore index 50335d9..c155584 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,14 @@ export_credentials.cfg .mono/ data_*/ mono_crash.*.json + +# C++ build artifacts +*.os +*.o +*.so +*.dll +*.dylib +*.framework +.sconsign.dblite +bin/ +demo/bin/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b482555 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "godot-cpp"] + path = godot-cpp + url = https://github.com/godotengine/godot-cpp.git +[submodule "libvterm"] + path = libvterm + url = https://github.com/neovim/libvterm.git diff --git a/SConstruct b/SConstruct new file mode 100644 index 0000000..54ae220 --- /dev/null +++ b/SConstruct @@ -0,0 +1,40 @@ +#!/usr/bin/env python + +env = SConscript("godot-cpp/SConstruct") + +env.Append(CPPPATH=["src/", "libvterm/include"]) +sources = Glob("src/*.cpp") + +libvterm_sources = Glob("libvterm/src/*.c") +sources += libvterm_sources + +if env["platform"] in ["linux", "linuxbsd"]: + env.Append(LIBS=["util"]) + env.Append(CPPDEFINES=["_GNU_SOURCE"]) + +if env["platform"] == "macos": + library = env.SharedLibrary( + "demo/bin/libshell.{}.{}.framework/libshell.{}.{}".format( + env["platform"], env["target"], env["platform"], env["target"] + ), + source=sources, + ) +elif env["platform"] == "ios": + if env["ios_simulator"]: + library = env.StaticLibrary( + "demo/bin/libshell.{}.{}.simulator.a".format(env["platform"], env["target"]), + source=sources, + ) + else: + library = env.StaticLibrary( + "demo/bin/libshell.{}.{}.a".format(env["platform"], env["target"]), + source=sources, + ) +else: + library = env.SharedLibrary( + "demo/bin/libshell{}{}".format(env["suffix"], env["SHLIBSUFFIX"]), + source=sources, + ) + +env.NoCache(library) +Default(library) diff --git a/demo/shell.gdextension b/demo/shell.gdextension new file mode 100644 index 0000000..fb901e2 --- /dev/null +++ b/demo/shell.gdextension @@ -0,0 +1,21 @@ +[gd_resource type="GDExtension" format=3] + +[configuration] + +entry_symbol = "shell_library_init" +compatibility_minimum = "4.2" + +[libraries] + +linux.debug.x86_64 = "res://bin/libshell.linux.template_debug.x86_64.so" +linux.release.x86_64 = "res://bin/libshell.linux.template_release.x86_64.so" +linux.editor.x86_64 = "res://bin/libshell.linux.editor.x86_64.so" +windows.debug.x86_64 = "res://bin/libshell.windows.template_debug.x86_64.dll" +windows.release.x86_64 = "res://bin/libshell.windows.template_release.x86_64.dll" +windows.editor.x86_64 = "res://bin/libshell.windows.editor.x86_64.dll" +macos.debug = "res://bin/libshell.macos.template_debug.framework" +macos.release = "res://bin/libshell.macos.template_release.framework" +macos.editor = "res://bin/libshell.macos.editor.framework" +macos.debug.arm64 = "res://bin/libshell.macos.template_debug.arm64.framework" +macos.release.arm64 = "res://bin/libshell.macos.template_release.arm64.framework" +macos.editor.arm64 = "res://bin/libshell.macos.editor.arm64.framework" diff --git a/godot-cpp b/godot-cpp new file mode 160000 index 0000000..9ae37ac --- /dev/null +++ b/godot-cpp @@ -0,0 +1 @@ +Subproject commit 9ae37ac8b9b14df5284dc3d4bf87e7d8b3327503 diff --git a/libvterm b/libvterm new file mode 160000 index 0000000..934bc2f --- /dev/null +++ b/libvterm @@ -0,0 +1 @@ +Subproject commit 934bc2fbf21800ac3458a499df8820ca5fb45fd3 diff --git a/src/shell_pty.cpp b/src/shell_pty.cpp new file mode 100644 index 0000000..5c074df --- /dev/null +++ b/src/shell_pty.cpp @@ -0,0 +1,152 @@ +#include "shell_pty.h" + +#include +#include + +#if defined(_WIN32) +// Windows ConPTY placeholder (requires Windows 10 SDK) +#else +#include +#include +#if defined(__APPLE__) || defined(__FreeBSD__) +#include +#else +#include +#endif +#include +#include +#include +#include +#endif + +using namespace godot; + +void PTY::_bind_methods() { + ClassDB::bind_method(D_METHOD("start", "command", "rows", "cols"), &PTY::start, DEFVAL(24), DEFVAL(80)); + ClassDB::bind_method(D_METHOD("stop"), &PTY::stop); + ClassDB::bind_method(D_METHOD("read_data"), &PTY::read_data); + ClassDB::bind_method(D_METHOD("write_data", "data"), &PTY::write_data); + ClassDB::bind_method(D_METHOD("resize", "rows", "cols"), &PTY::resize); + ClassDB::bind_method(D_METHOD("is_running"), &PTY::is_running); +} + +PTY::PTY() { + master_fd = -1; + pid = -1; +} + +PTY::~PTY() { + stop(); +} + +bool PTY::start(const String &p_command, int p_rows, int p_cols) { + if (is_running()) { + stop(); + } + +#if defined(_WIN32) + UtilityFunctions::printerr("Windows ConPTY not yet implemented in this proof-of-concept."); + return false; +#else + struct winsize winp; + winp.ws_row = p_rows; + winp.ws_col = p_cols; + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + + pid = forkpty(&master_fd, nullptr, nullptr, &winp); + + if (pid < 0) { + UtilityFunctions::printerr("forkpty failed"); + return false; + } else if (pid == 0) { + // Child process + setenv("TERM", "xterm-256color", 1); + + std::string cmd = p_command.utf8().get_data(); + if (cmd.empty()) { + cmd = "/bin/sh"; + } + + const char *args[] = { cmd.c_str(), nullptr }; + execvp(args[0], (char *const *)args); + + // If execvp fails + exit(1); + } + + // Parent process + // Set master_fd to non-blocking + int flags = fcntl(master_fd, F_GETFL, 0); + fcntl(master_fd, F_SETFL, flags | O_NONBLOCK); + + return true; +#endif +} + +void PTY::stop() { +#if !defined(_WIN32) + if (pid > 0) { + kill(pid, SIGTERM); + waitpid(pid, nullptr, 0); + pid = -1; + } + if (master_fd != -1) { + close(master_fd); + master_fd = -1; + } +#endif +} + +PackedByteArray PTY::read_data() { + PackedByteArray pba; +#if !defined(_WIN32) + if (master_fd != -1) { + char buffer[4096]; + ssize_t bytes_read = read(master_fd, buffer, sizeof(buffer)); + if (bytes_read > 0) { + pba.resize(bytes_read); + memcpy(pba.ptrw(), buffer, bytes_read); + } + } +#endif + return pba; +} + +void PTY::write_data(const PackedByteArray &p_data) { +#if !defined(_WIN32) + if (master_fd != -1 && p_data.size() > 0) { + // We ignore write errors for this simple example + [[maybe_unused]] ssize_t result = write(master_fd, p_data.ptr(), p_data.size()); + } +#endif +} + +void PTY::resize(int p_rows, int p_cols) { +#if !defined(_WIN32) + if (master_fd != -1) { + struct winsize winp; + winp.ws_row = p_rows; + winp.ws_col = p_cols; + winp.ws_xpixel = 0; + winp.ws_ypixel = 0; + ioctl(master_fd, TIOCSWINSZ, &winp); + } +#endif +} + +bool PTY::is_running() const { +#if !defined(_WIN32) + if (pid > 0) { + int status; + pid_t result = waitpid(pid, &status, WNOHANG); + if (result == 0) { + return true; + } else if (result == pid) { + // Process died, waitpid consumed the zombie + return false; + } + } +#endif + return false; +} diff --git a/src/shell_pty.h b/src/shell_pty.h new file mode 100644 index 0000000..2b10e43 --- /dev/null +++ b/src/shell_pty.h @@ -0,0 +1,36 @@ +#ifndef SHELL_PTY_H +#define SHELL_PTY_H + +#include +#include +#include + +namespace godot { + +class PTY : public RefCounted { + GDCLASS(PTY, RefCounted) + +private: + int master_fd; + int pid; + +protected: + static void _bind_methods(); + +public: + PTY(); + ~PTY(); + + bool start(const String &p_command, int p_rows, int p_cols); + void stop(); + + PackedByteArray read_data(); + void write_data(const PackedByteArray &p_data); + void resize(int p_rows, int p_cols); + + bool is_running() const; +}; + +} // namespace godot + +#endif // SHELL_PTY_H diff --git a/src/terminal.cpp b/src/terminal.cpp new file mode 100644 index 0000000..6c00a37 --- /dev/null +++ b/src/terminal.cpp @@ -0,0 +1,275 @@ +#include "terminal.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace godot; + +void Terminal::_bind_methods() { + ClassDB::bind_method(D_METHOD("start", "command"), &Terminal::start, DEFVAL("")); + ClassDB::bind_method(D_METHOD("stop"), &Terminal::stop); +} + +Terminal::Terminal() { + pty.instantiate(); + rows = 24; + cols = 80; + vterm = vterm_new(rows, cols); + vterm_set_utf8(vterm, 1); + + vterm_screen = vterm_obtain_screen(vterm); + vterm_screen_enable_altscreen(vterm_screen, 1); + + VTermScreenCallbacks cb; + memset(&cb, 0, sizeof(cb)); + cb.damage = _vterm_screen_damage; + cb.moverect = _vterm_screen_moverect; + cb.movecursor = _vterm_screen_movecursor; + cb.settermprop = _vterm_screen_settermprop; + cb.bell = _vterm_screen_bell; + cb.resize = _vterm_screen_resize; + cb.sb_pushline = _vterm_screen_sb_pushline; + cb.sb_popline = _vterm_screen_sb_popline; + vterm_screen_set_callbacks(vterm_screen, &cb, this); + + vterm_output_set_callback(vterm, _vterm_output, this); + + VTermState *state = vterm_obtain_state(vterm); + vterm_state_reset(state, 1); + vterm_screen_reset(vterm_screen, 1); + + font_size = 14; + set_process(true); +} + +Terminal::~Terminal() { + if (vterm) { + vterm_free(vterm); + vterm = nullptr; + } +} + +void Terminal::_ready() { + if (font.is_null()) { + font = ThemeDB::get_singleton()->get_fallback_font(); + font_size = ThemeDB::get_singleton()->get_fallback_font_size(); + } + set_focus_mode(FOCUS_ALL); +} + +void Terminal::_notification(int p_what) { + switch (p_what) { + case NOTIFICATION_DRAW: { + _draw(); + break; + } + case NOTIFICATION_RESIZED: { + update_term_size(); + break; + } + } +} + +void Terminal::update_term_size() { + if (font.is_null()) return; + + Vector2 size = get_size(); + + float char_width = font->get_char_size('W', font_size).x; + if (char_width <= 0.0f) char_width = 8.0f; + + float char_height = font->get_height(font_size); + if (char_height <= 0.0f) char_height = 14.0f; + + int new_cols = Math::max(1, (int)(size.x / char_width)); + int new_rows = Math::max(1, (int)(size.y / char_height)); + + if (new_cols != cols || new_rows != rows) { + cols = new_cols; + rows = new_rows; + vterm_set_size(vterm, rows, cols); + if (pty->is_running()) { + pty->resize(rows, cols); + } + queue_redraw(); + } +} + +void Terminal::_process(double delta) { + if (pty->is_running()) { + PackedByteArray data = pty->read_data(); + if (data.size() > 0) { + vterm_input_write(vterm, (const char*)data.ptr(), data.size()); + vterm_screen_flush_damage(vterm_screen); + queue_redraw(); + } + } +} + +Color Terminal::vterm_color_to_godot(const VTermColor *vcolor, bool is_fg) { + if (!vcolor) { + return is_fg ? Color(0.8, 0.8, 0.8) : Color(0, 0, 0, 1.0); + } + VTermColor c = *vcolor; + vterm_screen_convert_color_to_rgb(vterm_screen, &c); + return Color(c.rgb.red / 255.0f, c.rgb.green / 255.0f, c.rgb.blue / 255.0f); +} + +void Terminal::_draw() { + if (font.is_null() || !vterm_screen) return; + + float char_width = font->get_char_size('W', font_size).x; + if (char_width <= 0) char_width = 8; + float char_height = font->get_height(font_size); + float ascent = font->get_ascent(font_size); + + // Clear background + draw_rect(Rect2(0, 0, get_size().x, get_size().y), Color(0, 0, 0, 1.0)); + + for (int row = 0; row < rows; ++row) { + for (int col = 0; col < cols; ++col) { + VTermPos pos; + pos.row = row; + pos.col = col; + + VTermScreenCell cell; + vterm_screen_get_cell(vterm_screen, pos, &cell); + + // Draw cell background + Color bg = vterm_color_to_godot(&cell.bg, false); + if (cell.attrs.reverse) { + bg = vterm_color_to_godot(&cell.fg, true); + } + + if (bg != Color(0, 0, 0, 1)) { + draw_rect(Rect2(col * char_width, row * char_height, char_width, char_height), bg); + } + + if (cell.chars[0] == 0) continue; + + // Draw cell text + Color fg = vterm_color_to_godot(&cell.fg, true); + if (cell.attrs.reverse) { + fg = vterm_color_to_godot(&cell.bg, false); + } + + String txt; + for (int i = 0; i < VTERM_MAX_CHARS_PER_CELL && cell.chars[i] != 0; ++i) { + txt += String::chr(cell.chars[i]); + } + draw_string(font, Vector2(col * char_width, row * char_height + ascent), txt, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, fg); + } + } + + // Draw Cursor + if (has_focus()) { + VTermState *state = vterm_obtain_state(vterm); + VTermPos cpos; + vterm_state_get_cursorpos(state, &cpos); + if (cpos.row >= 0 && cpos.row < rows && cpos.col >= 0 && cpos.col < cols) { + draw_rect(Rect2(cpos.col * char_width, cpos.row * char_height, char_width, char_height), Color(1, 1, 1, 0.5)); + } + } +} + +void Terminal::_gui_input(const Ref &event) { + Ref key = event; + if (key.is_valid() && key->is_pressed()) { // Removed !key->is_echo() so holding works! + if (pty->is_running()) { + VTermModifier mod = VTERM_MOD_NONE; + if (key->is_shift_pressed()) mod = (VTermModifier)(mod | VTERM_MOD_SHIFT); + if (key->is_ctrl_pressed()) mod = (VTermModifier)(mod | VTERM_MOD_CTRL); + if (key->is_alt_pressed()) mod = (VTermModifier)(mod | VTERM_MOD_ALT); + + Key k = key->get_keycode(); + + // Map some special keys that don't have unicode characters or require VTerm handling + VTermKey vk = VTERM_KEY_NONE; + switch (k) { + case KEY_ENTER: vk = VTERM_KEY_ENTER; break; + case KEY_TAB: vk = VTERM_KEY_TAB; break; + case KEY_BACKSPACE: vk = VTERM_KEY_BACKSPACE; break; + case KEY_ESCAPE: vk = VTERM_KEY_ESCAPE; break; + case KEY_UP: vk = VTERM_KEY_UP; break; + case KEY_DOWN: vk = VTERM_KEY_DOWN; break; + case KEY_LEFT: vk = VTERM_KEY_LEFT; break; + case KEY_RIGHT: vk = VTERM_KEY_RIGHT; break; + case KEY_PAGEUP: vk = VTERM_KEY_PAGEUP; break; + case KEY_PAGEDOWN: vk = VTERM_KEY_PAGEDOWN; break; + case KEY_HOME: vk = VTERM_KEY_HOME; break; + case KEY_END: vk = VTERM_KEY_END; break; + case KEY_INSERT: vk = VTERM_KEY_INS; break; + case KEY_DELETE: vk = VTERM_KEY_DEL; break; + default: break; + } + + if (vk != VTERM_KEY_NONE) { + vterm_keyboard_key(vterm, vk, mod); + } else { + uint32_t uc = key->get_unicode(); + if (uc > 0) { + vterm_keyboard_unichar(vterm, uc, mod); + } + } + } + accept_event(); + } +} + +void Terminal::start(const String &p_command) { + if (pty->start(p_command, rows, cols)) { + queue_redraw(); + } +} + +void Terminal::stop() { + pty->stop(); +} + +void Terminal::_vterm_output(const char *s, size_t len, void *user) { + Terminal *term = static_cast(user); + if (term->pty->is_running()) { + PackedByteArray pba; + pba.resize(len); + memcpy(pba.ptrw(), s, len); + term->pty->write_data(pba); + } +} + +int Terminal::_vterm_screen_damage(VTermRect rect, void *user) { + return 1; +} + +int Terminal::_vterm_screen_moverect(VTermRect dest, VTermRect src, void *user) { + return 1; +} + +int Terminal::_vterm_screen_movecursor(VTermPos pos, VTermPos oldpos, int visible, void *user) { + return 1; +} + +int Terminal::_vterm_screen_settermprop(VTermProp prop, VTermValue *val, void *user) { + return 1; +} + +int Terminal::_vterm_screen_bell(void *user) { + return 1; +} + +int Terminal::_vterm_screen_resize(int rows, int cols, void *user) { + return 1; +} + +int Terminal::_vterm_screen_sb_pushline(int cols, const VTermScreenCell *cells, void *user) { + return 1; +} + +int Terminal::_vterm_screen_sb_popline(int cols, VTermScreenCell *cells, void *user) { + return 1; +} diff --git a/src/terminal.h b/src/terminal.h new file mode 100644 index 0000000..a39ab23 --- /dev/null +++ b/src/terminal.h @@ -0,0 +1,61 @@ +#ifndef TERMINAL_H +#define TERMINAL_H + +#include +#include +#include +#include +#include +#include +#include "shell_pty.h" +#include + +namespace godot { + +class Terminal : public Control { + GDCLASS(Terminal, Control) + +private: + Ref pty; + VTerm *vterm; + VTermScreen *vterm_screen; + + int rows; + int cols; + + Ref font; + int font_size; + + static void _vterm_output(const char *s, size_t len, void *user); + static int _vterm_screen_damage(VTermRect rect, void *user); + static int _vterm_screen_moverect(VTermRect dest, VTermRect src, void *user); + static int _vterm_screen_movecursor(VTermPos pos, VTermPos oldpos, int visible, void *user); + static int _vterm_screen_settermprop(VTermProp prop, VTermValue *val, void *user); + static int _vterm_screen_bell(void *user); + static int _vterm_screen_resize(int rows, int cols, void *user); + static int _vterm_screen_sb_pushline(int cols, const VTermScreenCell *cells, void *user); + static int _vterm_screen_sb_popline(int cols, VTermScreenCell *cells, void *user); + + void update_term_size(); + Color vterm_color_to_godot(const VTermColor *vcolor, bool is_fg); + +protected: + static void _bind_methods(); + +public: + Terminal(); + ~Terminal(); + + void _ready() override; + void _process(double delta) override; + void _draw() override; + void _gui_input(const Ref &event) override; + void _notification(int p_what); + + void start(const String &p_command); + void stop(); +}; + +} // namespace godot + +#endif // TERMINAL_H