683 lines
25 KiB
C++
683 lines
25 KiB
C++
|
|
#include "editor/terminal.h"
|
|||
|
|
|
|||
|
|
#include <raylib.h>
|
|||
|
|
|
|||
|
|
#include <algorithm>
|
|||
|
|
#include <cstring>
|
|||
|
|
|
|||
|
|
#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 <windows.h>
|
|||
|
|
#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<int> 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<std::string>& 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<char> 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<wchar_t> w(size_t(wide))`: это объявление ФУНКЦИИ,
|
|||
|
|
// а не вектора (most vexing parse), и компилятор скажет об этом невнятно.
|
|||
|
|
std::vector<wchar_t> 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<char> 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<char> 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<std::string> view;
|
|||
|
|
std::vector<Color> 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<std::string> 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);
|
|||
|
|
}
|