squad-proto/editor/terminal.cpp

683 lines
25 KiB
C++
Raw Normal View History

Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
#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);
}