#include "editor/terminal.h" #include #include #include #include "engine/framebuffer.h" #ifdef _WIN32 // raylib и Win32 объявляют CloseWindow, ShowCursor и DrawTextEx каждый по-своему, // и в одном файле они не уживаются. NOGDI и NOUSER выкидывают из windows.h // именно те две части, где это происходит: рисование и оконный интерфейс нам // отсюда не нужны — нужны процессы и каналы. #define WIN32_LEAN_AND_MEAN #define NOMINMAX #define NOGDI #define NOUSER #include #endif namespace { // Сколько строк держим. Не «на всякий случай»: прогон --tool map печатает // полсотни строк, сборка — сотни, и без потолка панель съедала бы память тем // быстрее, чем активнее ей пользуются. constexpr size_t SCROLLBACK_MAX = 4000; constexpr int FONT_SIZE = 16; constexpr int LINE_H = 18; constexpr int PAD = 8; constexpr int BTN_H = 20; const Color COL_BG = {10, 10, 13, 255}; // непрозрачно: подложка редактора не должна просвечивать const Color COL_EDGE = {52, 50, 56, 255}; const Color COL_TEXT = {198, 196, 190, 255}; const Color COL_DIM = {112, 110, 106, 255}; const Color COL_ECHO = {150, 190, 230, 255}; // что мы сами запустили const Color COL_ERR = {214, 108, 88, 255}; const Color COL_OK = {130, 200, 140, 255}; const Color COL_BTN = {26, 26, 31, 255}; const Color COL_BTN_HI = {74, 44, 36, 255}; struct Button { const char* label; const char* cmd; // что запустить; nullptr — особое действие int x = 0, w = 0; }; // Кнопки — это не «удобство», а список того, что при работе с моделью // запускают чаще всего. Каждая из них — команда, которую можно набрать руками; // ничего, кроме экономии нажатий, они не делают. Button BUTTONS[] = { {"VALIDATE", "tile2d_editor --tool validate", 0, 0}, {"ROOMS", "tile2d_editor --tool rooms", 0, 0}, {"FLOOR", "tile2d_editor --tool floor 6", 0, 0}, {"MAP", "tile2d_editor --tool map 6", 0, 0}, {"CURVE", "tile2d_editor --tool curve 1 12", 0, 0}, {"PLAY", "squad_proto --rooms", 0, 0}, {"STOP", nullptr, 0, 0}, {"EXTERNAL", nullptr, 0, 0}, }; constexpr int BUTTON_COUNT = int(sizeof(BUTTONS) / sizeof(BUTTONS[0])); // --- UTF-8 ------------------------------------------------------------------- // Ввод у raylib приходит кодовыми точками, вывод процессов — байтами UTF-8. // Между ними нужен перевод, иначе набранная кириллица уедет в мусор. void AppendUtf8(std::string& s, int cp) { if (cp < 0x80) s += char(cp); else if (cp < 0x800) { s += char(0xC0 | (cp >> 6)); s += char(0x80 | (cp & 0x3F)); } else if (cp < 0x10000) { s += char(0xE0 | (cp >> 12)); s += char(0x80 | ((cp >> 6) & 0x3F)); s += char(0x80 | (cp & 0x3F)); } } // Backspace обязан снимать СИМВОЛ, а не байт: иначе от кириллической буквы // остаётся половина, и строка перестаёт быть валидным UTF-8. void PopUtf8(std::string& s) { while (!s.empty() && (uint8_t(s.back()) & 0xC0) == 0x80) s.pop_back(); if (!s.empty()) s.pop_back(); } // Убрать управляющие последовательности ANSI. Мы не эмулятор терминала: // цвета и адресация курсора нам не нужны, а мусор вида `ESC[0m` в строке — // нужен ещё меньше. std::string StripAnsi(const std::string& in) { std::string out; out.reserve(in.size()); for (size_t i = 0; i < in.size(); ++i) { const char c = in[i]; if (c == '\x1B') { if (i + 1 < in.size() && in[i + 1] == '[') { i += 2; while (i < in.size() && !(in[i] >= '@' && in[i] <= '~')) ++i; } else { ++i; // односимвольная последовательность } continue; } if (c == '\r') continue; if (c == '\t') { out += " "; continue; } if (uint8_t(c) < 0x20) continue; out += c; } return out; } std::string Quote(const std::string& s) { std::string out = "\""; for (const char c : s) { if (c == '"' || c == '\\') out += '\\'; out += c; } out += '"'; return out; } // --- состояние, которому нужны windows.h и raylib ---------------------------- struct Impl { Font font{}; bool fontLoaded = false; std::string pending; // недобранный хвост строки #ifdef _WIN32 HANDLE proc = nullptr; HANDLE outRead = nullptr; #endif }; Impl g_impl; // Шрифт с кириллицей. Встроенный шрифт raylib её не знает, и вывод модели, // которая отвечает по-русски, превратился бы в частокол вопросов. Правило // «тексты на экране латиницей» здесь не нарушается: оно про ИГРУ, где текст // пишет разработчик, а тут текст приходит извне и выбора нет. void EnsureFont() { if (g_impl.fontLoaded) return; g_impl.fontLoaded = true; std::vector cps; for (int c = 32; c < 127; ++c) cps.push_back(c); for (int c = 0x0400; c <= 0x045F; ++c) cps.push_back(c); cps.push_back(0x2014); // тире cps.push_back(0x2026); // многоточие cps.push_back(0x00AB); cps.push_back(0x00BB); static const char* const CANDIDATES[] = { "C:/Windows/Fonts/consola.ttf", "C:/Windows/Fonts/cour.ttf", "C:/Windows/Fonts/lucon.ttf", }; for (const char* path : CANDIDATES) { if (!FileExists(path)) continue; g_impl.font = LoadFontEx(path, FONT_SIZE * 2, cps.data(), int(cps.size())); if (g_impl.font.texture.id != 0) { SetTextureFilter(g_impl.font.texture, TEXTURE_FILTER_BILINEAR); return; } } g_impl.font = GetFontDefault(); // латиница есть, и на том спасибо } void DrawLine(const char* text, int x, int y, Color c) { DrawTextEx(g_impl.font, text, Vector2{float(x), float(y)}, float(FONT_SIZE), 1.0f, c); } int MeasureLine(const char* text) { return int(MeasureTextEx(g_impl.font, text, float(FONT_SIZE), 1.0f).x); } // Разбить строку на куски по cols символов. Ответ модели — это абзац на // несколько экранных ширин, и без переноса он просто уезжает за правый край: // прочитать нельзя, а что текст оборван — не видно. // // Считаем СИМВОЛЫ, а не байты: кириллица в UTF-8 занимает два байта, и разрез // по байтам оставил бы половину буквы, то есть сломанный UTF-8 в середине. void WrapInto(const std::string& s, int cols, std::vector& out) { if (cols < 8) cols = 8; size_t i = 0; while (i < s.size()) { size_t j = i; int chars = 0; while (j < s.size() && chars < cols) { ++j; while (j < s.size() && (uint8_t(s[j]) & 0xC0) == 0x80) ++j; // хвост символа ++chars; } out.push_back(s.substr(i, j - i)); i = j; } if (s.empty()) out.push_back(std::string()); } } // namespace const char* AskModeName(AskMode m) { switch (m) { case AskMode::SHELL: return "shell"; case AskMode::CLAUDE: return "claude"; case AskMode::CODEX: return "codex"; default: return "?"; } } void Terminal::Init(const std::string& dir, const std::string& project) { workDir = dir; projectName = project; EnsureFont(); Print("Tile2D terminal. Tab — режим (shell/claude/codex), Enter — выполнить."); if (!projectName.empty()) Print("проект " + projectName + " (TILE2D_PROJECT), каталог " + workDir); Print("`--tool help` покажет инструменты движка; F12 — интерактивный CLI отдельным окном."); } void Terminal::Shutdown() { Stop(); } bool Terminal::Running() const { #ifdef _WIN32 return g_impl.proc != nullptr; #else return false; #endif } void Terminal::Print(const std::string& s) { lines.push_back(s); if (lines.size() > SCROLLBACK_MAX) lines.erase(lines.begin(), lines.begin() + int(lines.size() - SCROLLBACK_MAX)); scroll = 0; // новая строка возвращает к концу: иначе вывод «не идёт» } int Terminal::PanelTop(int windowH) const { if (!open) return windowH; return windowH - std::max(160, windowH * 2 / 5); } // ----------------------------------------------------------------------------- // Процесс // ----------------------------------------------------------------------------- #ifdef _WIN32 namespace { void CloseChild() { if (g_impl.proc) { CloseHandle(g_impl.proc); g_impl.proc = nullptr; } if (g_impl.outRead) { CloseHandle(g_impl.outRead); g_impl.outRead = nullptr; } } } // namespace void Terminal::RunShell(const std::string& cmd) { if (cmd.empty()) return; if (Running()) { Print("занято: команда уже выполняется (STOP прервёт)"); return; } Print("$ " + cmd); SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof(sa); sa.bInheritHandle = TRUE; HANDLE outWrite = nullptr; if (!CreatePipe(&g_impl.outRead, &outWrite, &sa, 0)) { Print("не удалось создать канал"); CloseChild(); return; } // Наш конец канала дочернему процессу не наследуется: иначе он держал бы // его открытым, и признак конца вывода не пришёл бы никогда. SetHandleInformation(g_impl.outRead, HANDLE_FLAG_INHERIT, 0); // Ввод — устройство NUL, а не канал. Канал, в который никто никогда не // пишет, выглядит для потомка как «данные вот-вот придут»: claude честно // ждёт три секунды и печатает предупреждение. Пустой ввод — это EOF, и // сказать это надо сразу. HANDLE nul = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, nullptr); STARTUPINFOA si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESTDHANDLES; si.hStdOutput = outWrite; si.hStdError = outWrite; si.hStdInput = nul; PROCESS_INFORMATION pi{}; // Через cmd.exe, а не напрямую: нужны PATH, встроенные команды и то, что // человек привык набирать в консоли. Кодовая страница 65001 — иначе // кириллица из вывода приезжает в CP866 и рисуется мусором. // // Каталог с бинарниками добавляется в PATH: команды выполняются в корне // репозитория (там содержимое, документация и git), а `tile2d_editor` и // `squad_proto` лежат в build/bin. Без этого простое `--tool validate` // требовало бы писать путь к самому себе. char exePath[MAX_PATH] = {}; GetModuleFileNameA(nullptr, exePath, MAX_PATH); std::string binDir = exePath; const size_t slash = binDir.find_last_of("\\/"); binDir = (slash == std::string::npos) ? std::string(".") : binDir.substr(0, slash); std::string line = "cmd.exe /d /c chcp 65001>nul & set \"PATH=" + binDir + ";%PATH%\" & "; if (!projectName.empty()) line += "set \"TILE2D_PROJECT=" + projectName + "\" & "; line += cmd; std::vector mutableLine(line.begin(), line.end()); mutableLine.push_back('\0'); const BOOL ok = CreateProcessA(nullptr, mutableLine.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, workDir.empty() ? nullptr : workDir.c_str(), &si, &pi); CloseHandle(outWrite); if (nul != INVALID_HANDLE_VALUE) CloseHandle(nul); if (!ok) { Print("не запустилось: " + cmd); CloseChild(); return; } CloseHandle(pi.hThread); g_impl.proc = pi.hProcess; } void Terminal::RunShellFromArgv(const char* cmd) { if (!cmd) return; const int wide = MultiByteToWideChar(CP_ACP, 0, cmd, -1, nullptr, 0); if (wide <= 0) { RunShell(cmd); return; } // Не `std::vector w(size_t(wide))`: это объявление ФУНКЦИИ, // а не вектора (most vexing parse), и компилятор скажет об этом невнятно. std::vector w; w.resize(size_t(wide)); MultiByteToWideChar(CP_ACP, 0, cmd, -1, w.data(), wide); const int utf8 = WideCharToMultiByte(CP_UTF8, 0, w.data(), -1, nullptr, 0, nullptr, nullptr); if (utf8 <= 0) { RunShell(cmd); return; } std::vector u; u.resize(size_t(utf8)); WideCharToMultiByte(CP_UTF8, 0, w.data(), -1, u.data(), utf8, nullptr, nullptr); RunShell(u.data()); } void Terminal::Stop() { if (!Running()) return; TerminateProcess(g_impl.proc, 1); WaitForSingleObject(g_impl.proc, 2000); CloseChild(); Print("[прервано]"); } void Terminal::Poll() { if (!g_impl.outRead) return; for (;;) { DWORD avail = 0; if (!PeekNamedPipe(g_impl.outRead, nullptr, 0, nullptr, &avail, nullptr)) break; if (avail == 0) break; char buf[8192]; DWORD got = 0; const DWORD want = (avail < sizeof(buf)) ? avail : DWORD(sizeof(buf)); if (!ReadFile(g_impl.outRead, buf, want, &got, nullptr) || got == 0) break; g_impl.pending.append(buf, got); for (;;) { const size_t nl = g_impl.pending.find('\n'); if (nl == std::string::npos) break; Print(StripAnsi(g_impl.pending.substr(0, nl))); g_impl.pending.erase(0, nl + 1); } // Очень длинная строка без перевода — не повод копить её вечно. if (g_impl.pending.size() > 4096) { Print(StripAnsi(g_impl.pending)); g_impl.pending.clear(); } } if (g_impl.proc && WaitForSingleObject(g_impl.proc, 0) == WAIT_OBJECT_0) { if (!g_impl.pending.empty()) { Print(StripAnsi(g_impl.pending)); g_impl.pending.clear(); } DWORD code = 0; GetExitCodeProcess(g_impl.proc, &code); Print(code == 0 ? "[готово]" : ("[код " + std::to_string(int(code)) + "]")); CloseChild(); } } void Terminal::LaunchExternal() { // Полный интерактивный CLI — во внешнем окне: у него альтернативный экран // и рамки из псевдографики, и рисовать это здесь было бы враньём. const char* cli = (mode == AskMode::CODEX) ? "codex" : "claude"; const std::string cmd = std::string("cmd.exe /c start \"Tile2D agent\" cmd /k ") + cli; std::vector line(cmd.begin(), cmd.end()); line.push_back('\0'); STARTUPINFOA si{}; si.cb = sizeof(si); PROCESS_INFORMATION pi{}; if (CreateProcessA(nullptr, line.data(), nullptr, nullptr, FALSE, 0, nullptr, workDir.empty() ? nullptr : workDir.c_str(), &si, &pi)) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); Print(std::string("внешнее окно: ") + cli + " в " + workDir); } else { Print(std::string("не удалось открыть внешний терминал с ") + cli); } } #else // не Windows void Terminal::RunShell(const std::string& cmd) { Print("терминал реализован только под Windows: " + cmd); } void Terminal::Stop() {} void Terminal::Poll() {} void Terminal::LaunchExternal() { Print("внешний терминал: только под Windows"); } void Terminal::RunShellFromArgv(const char* cmd) { if (cmd) RunShell(cmd); } #endif void Terminal::Submit() { const std::string line = input; if (line.empty()) return; input.clear(); histPos = -1; if (history.empty() || history.back() != line) history.push_back(line); switch (mode) { case AskMode::CLAUDE: RunShell("claude -p " + Quote(line)); break; case AskMode::CODEX: RunShell("codex exec " + Quote(line)); break; default: RunShell(line); break; } } // ----------------------------------------------------------------------------- // Ввод // ----------------------------------------------------------------------------- bool Terminal::Update(int windowW, int windowH, float dt) { (void)dt; if (IsKeyPressed(KEY_GRAVE)) open = !open; if (IsKeyPressed(KEY_F12)) { open = true; LaunchExternal(); } if (!open) return false; Poll(); const int top = PanelTop(windowH); // --- кнопки --------------------------------------------------------------- int bx = PAD; for (int i = 0; i < BUTTON_COUNT; ++i) { BUTTONS[i].w = MeasureLine(BUTTONS[i].label) + 14; BUTTONS[i].x = bx; bx += BUTTONS[i].w + 6; } const Vector2 m = GetMousePosition(); const bool inPanel = m.y >= float(top); if (inPanel && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { const int by = top + PAD; for (int i = 0; i < BUTTON_COUNT; ++i) { const bool hit = m.x >= float(BUTTONS[i].x) && m.x < float(BUTTONS[i].x + BUTTONS[i].w) && m.y >= float(by) && m.y < float(by + BTN_H); if (!hit) continue; if (BUTTONS[i].cmd) RunShell(BUTTONS[i].cmd); else if (std::strcmp(BUTTONS[i].label, "STOP") == 0) Stop(); else LaunchExternal(); } } // --- клавиши -------------------------------------------------------------- const bool ctrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); for (int cp = GetCharPressed(); cp > 0; cp = GetCharPressed()) if (cp != '`') AppendUtf8(input, cp); if (IsKeyPressed(KEY_BACKSPACE) || IsKeyPressedRepeat(KEY_BACKSPACE)) PopUtf8(input); if (IsKeyPressed(KEY_TAB)) mode = AskMode((int(mode) + 1) % int(AskMode::COUNT)); if (IsKeyPressed(KEY_ENTER) || IsKeyPressed(KEY_KP_ENTER)) Submit(); if (ctrl && IsKeyPressed(KEY_C)) Stop(); if (IsKeyPressed(KEY_ESCAPE)) { if (Running()) Stop(); else open = false; } // История: вверх — назад по своим командам. Ровно то поведение, которое // есть в любой оболочке; своё придумывать здесь незачем. if (IsKeyPressed(KEY_UP) && !history.empty()) { if (histPos < 0) histPos = int(history.size()); histPos = std::max(0, histPos - 1); input = history[size_t(histPos)]; } if (IsKeyPressed(KEY_DOWN) && histPos >= 0) { ++histPos; if (histPos >= int(history.size())) { histPos = -1; input.clear(); } else input = history[size_t(histPos)]; } const int rows = std::max(1, (windowH - top - PAD * 3 - BTN_H - LINE_H) / LINE_H); const int maxScroll = std::max(0, int(lines.size()) - rows); if (IsKeyPressed(KEY_PAGE_UP)) scroll = std::min(maxScroll, scroll + rows / 2); if (IsKeyPressed(KEY_PAGE_DOWN)) scroll = std::max(0, scroll - rows / 2); if (inPanel) { const float wheel = GetMouseWheelMove(); if (wheel != 0.0f) scroll = std::max(0, std::min(maxScroll, scroll + int(wheel * 3.0f))); } (void)windowW; return true; // терминал открыт — весь ввод его } void Terminal::Draw(int windowW, int windowH) const { if (!open) return; const int top = PanelTop(windowH); DrawRectangle(0, top, windowW, windowH - top, COL_BG); DrawRectangle(0, top, windowW, 1, COL_EDGE); // --- кнопки --------------------------------------------------------------- const int by = top + PAD; const Vector2 m = GetMousePosition(); for (int i = 0; i < BUTTON_COUNT; ++i) { const bool hover = m.x >= float(BUTTONS[i].x) && m.x < float(BUTTONS[i].x + BUTTONS[i].w) && m.y >= float(by) && m.y < float(by + BTN_H); DrawRectangle(BUTTONS[i].x, by, BUTTONS[i].w, BTN_H, hover ? COL_BTN_HI : COL_BTN); DrawRectangleLines(BUTTONS[i].x, by, BUTTONS[i].w, BTN_H, COL_EDGE); DrawLine(BUTTONS[i].label, BUTTONS[i].x + 7, by + 2, COL_TEXT); } // Состояние справа от кнопок: выполняется ли что-то и где именно. { const std::string right = (Running() ? std::string("выполняется ") : std::string()) + workDir; DrawLine(right.c_str(), windowW - PAD - MeasureLine(right.c_str()), by + 2, Running() ? COL_OK : COL_DIM); } // --- вывод ---------------------------------------------------------------- const int bodyTop = by + BTN_H + PAD; const int bodyBottom = windowH - PAD - LINE_H; const int rows = std::max(1, (bodyBottom - bodyTop) / LINE_H); // Сколько символов влезает в строку. Шаг буквы меряется по РАЗНОСТИ двух // строк, а не по одной букве: в шаг входит межбуквенный интервал, и без // него расчёт занижает ширину символа, а строка вылезает за край. const int charW = std::max(1, MeasureLine("WW") - MeasureLine("W")); const int cols = (windowW - PAD * 2) / charW; // Переносим с конца назад, пока не набрали экран: так отматывать можно по // ЭКРАННЫМ строкам, а не по исходным, и абзац на пять ширин не проскакивает // мимо одним щелчком колеса. std::vector view; std::vector colors; view.reserve(size_t(rows) + 8); int skip = scroll; for (int i = int(lines.size()) - 1; i >= 0 && int(view.size()) < rows + skip; --i) { const std::string& s = lines[size_t(i)]; Color c = COL_TEXT; if (!s.empty() && s[0] == '$') c = COL_ECHO; else if (s.rfind("[код", 0) == 0 || s.rfind("СЛОМАНО", 0) == 0 || s.rfind("ОШИБКА", 0) == 0) c = COL_ERR; else if (s.rfind("[готово]", 0) == 0) c = COL_OK; std::vector wrapped; WrapInto(s, cols, wrapped); for (int k = int(wrapped.size()) - 1; k >= 0; --k) { view.push_back(wrapped[size_t(k)]); colors.push_back(c); } } int y = bodyTop; const int firstRow = std::min(int(view.size()), rows + skip) - 1; for (int i = firstRow; i >= skip && y < bodyBottom; --i, y += LINE_H) DrawLine(view[size_t(i)].c_str(), PAD, y, colors[size_t(i)]); if (scroll > 0) { const std::string tag = "^ отмотано на " + std::to_string(scroll); DrawLine(tag.c_str(), windowW - PAD - MeasureLine(tag.c_str()), bodyTop, COL_DIM); } // --- строка ввода --------------------------------------------------------- const int iy = windowH - PAD - LINE_H + 2; std::string prompt = std::string(AskModeName(mode)) + (mode == AskMode::SHELL ? "$ " : "> "); DrawLine(prompt.c_str(), PAD, iy, mode == AskMode::SHELL ? COL_DIM : COL_ECHO); const int px = PAD + MeasureLine(prompt.c_str()); DrawLine(input.c_str(), px, iy, COL_TEXT); // Курсор мигает от системного времени, а не от кадра: на 60 fps счётчик // кадров дал бы разную частоту на разных мониторах. if (int(GetTime() * 2.0) % 2 == 0) DrawRectangle(px + MeasureLine(input.c_str()) + 1, iy + 2, 8, FONT_SIZE - 2, COL_TEXT); }