squad-proto/editor/terminal.cpp
z.kirill 7d6266aaf3 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-13 21:07:19 +03:00

683 lines
25 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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);
}