#include #include #include #include #include "entities/camera.hpp" #include "entities/world.hpp" #include "rendering/gl_renderer.hpp" #include "text/handler.hpp" #include "text/text.hpp" #include "utils/config.hpp" #include "wall/wall.hpp" namespace Nimbin { namespace Text { namespace { double editing_CharWidth() { static double w = 0.0; if (w == 0.0) { TokenPtr m1 = make_Token("M", Token::depth_wall, 0.003f, Vec3{0,0,0}); TokenPtr m2 = make_Token("MM", Token::depth_wall, 0.003f, Vec3{0,0,0}); w = 2.0 * static_cast(m2->half_extent) - 2.0 * static_cast(m1->half_extent); } return w; } double editing_HalfExtentY() { static double h = 0.0; if (h == 0.0) { TokenPtr m = make_Token("M", Token::depth_wall, 0.003f, Vec3{0,0,0}); h = static_cast(m->half_extent_y); } return h; } TokenPtr make_Number(const std::string& s, const Vec3& pos, float yaw, float scale) { TokenPtr t = make_Token(s, Token::depth_wall, scale, pos, Config::Color::orange); t->yaw = yaw; t->set_Color(Config::Color::orange); t->set_ColorSource(ColorSource::Static); return t; } void set_NumberText(Token::Data* t, const std::string& s) { if (t->text == s) return; t->retext(s); t->set_Color (Config::Color::code_keyword); t->set_ColorSource(ColorSource::Static); } } // namespace Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode) : _role(role), _layout(mode) { _transform = Wall::get_Transform(role, world_scale); _line_height = Config::Wall::line_height; _word_spacing = Config::Wall::free_space_dist; _append_color = Config::Color::dark_gray; // The editor is always-Editing: it keeps a persistent buffer (its source of // truth) even when not connected, and lays out on the editing grid at rest. // One relayout here establishes _char_w before the first thrown word. if (_layout == LayoutMode::Editing) { _vim = cpputils::make_unique(); _eye_y = World::get_FloorY() + 2.0; relayout_Editing(); // establishes _char_w; _face keeps its Pitch default (tilt at rest) } } void Handler::set_Layout(LayoutMode m) { if (m == _layout) return; _layout = m; relayout(); } void Handler::set_ShowNumbers(bool on, bool both_columns) { _show_numbers = on; _both_columns = both_columns; } void Handler::set_DefaultStyle(float depth, float scale, double line_height, double word_spacing) { _depth = depth; _scale = scale; _line_height = line_height; _word_spacing = word_spacing; } void Handler::clear() { _lines .clear(); _last_src .clear(); _label_off .clear(); _label_text.clear(); _view_tag = 0; _borrowed = nullptr; _view_tag = 0; mark_Dirty(); } void Handler::set_Lines(const DynArray& lines) { _lines.clear(); const ColorSource src = _lex_enabled ? ColorSource::Lexed : ColorSource::Static; for (const std::string& text : lines) { Line ln; ln.line_height = _line_height; ln.word_spacing = _word_spacing; size_t byte = 0; while (byte < text.size()) { std::string tk; const char c = text[byte]; if (c == ' ') { tk = " "; ++byte; } else if (c == '\t') { tk = "\t"; ++byte; } else { while (byte < text.size() && text[byte] != ' ' && text[byte] != '\t') { tk += text[byte]; ++byte; } } TokenPtr t = make_Token(tk, _depth, _scale, _transform.position, Config::Color::wall); t->yaw = _transform.yaw; t->set_ColorSource(src); ln.tokens.push_back(std::move(t)); } _lines.push_back(std::move(ln)); } // Editor (persistent buffer): make the buffer the authority for the editing // relayout, so loaded content shows as columned code and round-trips. if (_vim) { std::vector v; for (const std::string& s : lines) v.push_back(s); _vim->load(v); _vim_seen_version = _vim->get_Version(); } relayout(); mark_Dirty(); } void Handler::append_Line(const std::string& text) { //SPIKE("append_Line.detail"); for (Line& l : _lines) { l.y += _line_height; for (TokenPtr& t : l.tokens) if (t) t->pos.y += _line_height; } Line ln; ln.y = _append_y; TokenPtr t = make_Token(text, _depth, _scale, _transform.position, _append_color); t->yaw = _transform.yaw; t->pos = _transform.position; t->pos.y = _append_y; t->set_Color(_append_color); ln.tokens.push_back(std::move(t)); _lines.push_back(std::move(ln)); mark_Dirty(); } void Handler::append_Tokens(DynArray block) { DynArray toks; toks.reserve(block.size()); for (TokenPtr& t : block) if (t) toks.push_back(std::move(t)); if (toks.empty()) return; for (Line& l : _lines) { l.y += _line_height; for (TokenPtr& t : l.tokens) if (t) t->pos.y += _line_height; } double row_w = 0.0; for (size_t i = 0; i < toks.size(); i++) { row_w += 2.0 * (double)toks[i]->half_extent; if (i + 1 < toks.size()) row_w += _word_spacing; } Line ln; ln.y = _append_y; ln.line_height = _line_height; double cursor = row_w * 0.5; for (TokenPtr& t : toks) { const double hw = (double)t->half_extent; t->yaw = _transform.yaw; t->pos = _transform.position; t->pos.y = _append_y; t->pos.z = cursor - hw; t->body.mode = PhysicsMode::Static; t->body.vel = Vec3{0, 0, 0}; t->set_ColorSource(ColorSource::Static); t->set_Color(_append_color); cursor = t->pos.z - hw - _word_spacing; ln.tokens.push_back(std::move(t)); } _lines.push_back(std::move(ln)); mark_Dirty(); } TokenPtr Handler::detach(Token::Data* t) { if (edit_Active()) return nullptr; for (size_t line_idx = 0; line_idx < _lines.size(); ++line_idx) { Line& l = _lines[line_idx]; for (size_t i = 0; i < l.tokens.size(); i++) { if (l.tokens[i].get() == t) { TokenPtr out = std::move(l.tokens[i]); l.tokens.erase(l.tokens.begin() + static_cast(i)); if (l.tokens.empty()) _lines.erase(_lines.begin() + static_cast(line_idx)); mark_Dirty(); if (_vim) { sync_VimFromLines(); relayout_Editing(); } // keep buffer in step return out; } } } return nullptr; } void Handler::attach(DynArray tokens, const Vec3& impact) { if (tokens.empty()) return; if (_layout == LayoutMode::Editing) attach_Editing (tokens, impact); else attach_Display(tokens, impact); mark_Dirty(); } void Handler::clear_ToFloor(DynArray& out) { const double hover = World::get_FloorY() + 1.5; for (Line& l : _lines) { for (TokenPtr& t : l.tokens) { if (!t) continue; t->body.mode = PhysicsMode::Dynamic; t->body.vel = { 0.0, 1.5, 0.0 }; t->body.hover_height = hover; t->set_Color (Config::Color::floor); t->set_Depth (Token::depth_floor); t->set_ColorSource (ColorSource::Lint); out.push_back (std::move(t)); } } _lines.clear(); if (_vim) { // editor: clear the buffer too, else relayout resurrects _vim->load(std::vector{}); _vim_seen_version = _vim->get_Version(); } mark_Dirty(); } Handler::Target Handler::resolve_Target(double y) const { DynArray order; for (size_t i = 0; i < _lines.size(); i++) if (!_lines[i].is_Empty()) order.push_back(i); if (order.empty()) return { Target::Insert, 0, y, 0.0 }; std::sort(order.begin(), order.end(), [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; }); auto glyph_top = [&](size_t line_idx) { return _lines[line_idx].y + (double)_lines[line_idx].tokens.front()->half_extent_y + Config::Wall::merge_top_margin; }; auto underline = [&](size_t line_idx) { return _lines[line_idx].y - Config::Wall::underline_drop; }; // 1) Merge: within a line's glyph band. for (size_t line_idx : order) if (y <= glyph_top(line_idx) && y >= underline(line_idx)) return { Target::Merge, line_idx, 0.0, 0.0 }; const double LH = Config::Wall::line_height; // 2) Above the top line. { const size_t top = order.front(); if (y > glyph_top(top)) { const double min_y = _lines[top].y + LH; const double new_y = (y > min_y) ? y : min_y; return { Target::Insert, top, new_y, 0.0 }; } } // 3) Between two lines. for (size_t oi = 0; oi + 1 < order.size(); ++oi) { const size_t hi = order[oi]; const size_t lo = order[oi + 1]; if (y < underline(hi) && y > glyph_top(lo)) { const double hi_y = _lines[hi].y, lo_y = _lines[lo].y; const double gap = hi_y - lo_y; const double free_top = hi_y - LH; const double free_bot = lo_y + LH; if (gap >= 2.0 * LH && y <= free_top && y >= free_bot) return { Target::Insert, lo, y, 0.0 }; if (gap >= 2.0 * LH) return { Target::Insert, lo, (y > free_top) ? free_top : free_bot, 0.0 }; const double deficit = 2.0 * LH - gap; return { Target::Insert, lo, free_top, deficit }; } } // 4) Below the bottom line. { const size_t bot = order.back(); const double max_y = _lines[bot].y - LH; const double new_y = (y < max_y) ? y : max_y; return { Target::Insert, bot, new_y, 0.0 }; } } Line& Handler::apply_Target(const Target& t) { if (t.kind == Target::Merge) return _lines[t.line_index]; if (t.push_dist > 0.0 && t.line_index < _lines.size()) { const double push_below_y = _lines[t.line_index].y; for (Line& l : _lines) { if (l.y <= push_below_y + 1e-6) { l.y -= t.push_dist; for (TokenPtr& w : l.tokens) if (w) w->pos.y -= t.push_dist; if (l.num_left) l.num_left ->pos.y -= t.push_dist; if (l.num_right) l.num_right->pos.y -= t.push_dist; } } } Line fresh; fresh.y = t.new_y; fresh.line_height = _line_height; size_t pos = 0; while (pos < _lines.size() && _lines[pos].y > fresh.y) ++pos; auto it = _lines.insert(_lines.begin() + static_cast(pos), std::move(fresh)); return *it; } // Materialise whitespace tokens between adjacent words so the line round-trips // through as_Lines (raw concat) without merging. Space tokens are invisible on // the wall (Handler::render skips whitespace), so only array ORDER matters here // — their wall position is irrelevant; relayout_Editing repositions them on edit. void Handler::ensure_WordSeparators(Line& l) { for (size_t i = 0; i + 1 < l.tokens.size(); ) { const TokenPtr& a = l.tokens[i]; const TokenPtr& b = l.tokens[i + 1]; if (a && b && !a->is_Whitespace() && !b->is_Whitespace()) { TokenPtr sp = make_Token(" ", _depth, _scale, _transform.position, Config::Color::wall); sp->yaw = _transform.yaw; sp->pos = a->pos; // invisible; sits by its left neighbour sp->set_ColorSource(_lex_enabled ? ColorSource::Lexed : ColorSource::Static); l.tokens.insert(l.tokens.begin() + static_cast(i + 1), std::move(sp)); i += 2; // step past the word and the new space } else { ++i; } } } void Handler::attach_Display(DynArray& block, const Vec3& impact) { std::sort(block.begin(), block.end(), [](const TokenPtr& a, const TokenPtr& b){ if (!a) return false; if (!b) return true; return a->pos.z > b->pos.z; }); for (TokenPtr& w : block) { if (!w) continue; w->yaw = _transform.yaw; w->set_ColorSource(_lex_enabled ? ColorSource::Lexed : ColorSource::Static); w->set_Depth(_depth); } double block_w = 0.0; size_t live = 0; for (const TokenPtr& w : block) { if (!w) continue; if (live) block_w += Config::Wall::free_space_dist; block_w += 2.0 * (double)w->half_extent; ++live; } if (live == 0) return; Target t = resolve_Target(impact.y); Line& target = apply_Target(t); auto left_edge = [](const Token::Data* w){ return w->pos.z + (double)w->half_extent; }; auto right_edge = [](const Token::Data* w){ return w->pos.z - (double)w->half_extent; }; size_t insert_index = 0; for (const TokenPtr& w : target.tokens) if (w && w->pos.z > impact.z) ++insert_index; const size_t n = target.tokens.size(); double block_left_z = impact.z + block_w * 0.5; if (insert_index > 0) { const double max_left = right_edge(target.tokens[insert_index - 1].get()) - Config::Wall::free_space_dist; if (block_left_z > max_left) block_left_z = max_left; } if (insert_index < n) { const double block_right = block_left_z - block_w; const double min_right = left_edge(target.tokens[insert_index].get()) + Config::Wall::free_space_dist; if (block_right < min_right) { const double shift = min_right - block_right; for (size_t k = insert_index; k < n; ++k) target.tokens[k]->pos.z -= shift; } } // Lay the run out left->right (decreasing z), snap to the row height. double cursor = block_left_z; for (TokenPtr& w : block) { if (!w) continue; const double hw = (double)w->half_extent; const double center_z = cursor - hw; w->pos.y = target.y; w->pos.z = center_z; w->pos.x = _transform.position.x; cursor = center_z - hw - Config::Wall::free_space_dist; } // Insert into the line at the chosen slot, in visual order, taking ownership. size_t k = 0; for (TokenPtr& w : block) { if (!w) continue; target.tokens.insert( target.tokens.begin() + static_cast(insert_index + k), std::move(w)); ++k; } block.clear(); // Words just landed with no whitespace between them — materialise separator // tokens so begin_Edit / as_Lines won't merge them (e.g. "int" "x" → "intx"). ensure_WordSeparators(target); } bool Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const { if (!_vim || _char_w <= 0.0) return false; const auto& blines = _vim->get_Lines(); const int n = static_cast(blines.size()); if (n == 0) return false; int r = static_cast(SDL_lround((_row0_y - impact.y) / _line_height)); r = SDL_clamp(r, 0, n); if (r == n) { row = n; col = 0; return true; } const std::string& line = blines[static_cast(r)]; int c = static_cast(SDL_lround((_code_left_z - impact.z) / _char_w - 0.5)); if (c < 0) c = 0; const int len = (int)line.size(); if (c >= len) { row = r; col = c; return true; } c = SDL_clamp(c, 0, len); auto is_gap = [&](int i) { return i == 0 || i == (int)line.size() || line[(size_t)i] == ' ' || line[(size_t)(i - 1)] == ' '; }; if (!is_gap(c)) { int lo = c, hi = c; while (lo > 0 && !is_gap(lo)) --lo; while (hi < (int)line.size() && !is_gap(hi)) ++hi; c = (c - lo <= hi - c) ? lo : hi; } row = r; col = c; return true; } Handler::EditorHit Handler::classify_Impact(const Vec3& impact) const { EditorHit hit{ HitKind::NewLine, 0, 0, 0, 0 }; if (!_vim || _char_w <= 0.0) return hit; // empty/unmeasured → seed line 0 const auto& blines = _vim->get_Lines(); const int N = static_cast(blines.size()); const double lh = _line_height; auto col_From_Z = [&]() -> int { int c = static_cast(SDL_lround((_code_left_z - impact.z) / _char_w)); return c < 0 ? 0 : c; }; if (N == 0) { hit.kind = HitKind::NewLine; hit.row = 0; hit.col = col_From_Z(); return hit; } // ── vertical: a row is "owned" only within the glyph cap height (≈ an 'A'/'M', // ±_he_y about the row centre) — NOT the whole line height. Aiming in the gap // between two rows inserts a new line there; aiming above the first row or // below the last appends/prepends. This mirrors the uniform cell box used for // the horizontal hit, so "clearly above/below a token" makes a new line // instead of dropping it, while the inter-row gap can no longer masquerade as // a hit on the nearest token. ── const double rf = (_row0_y - impact.y) / lh; const int i = static_cast(SDL_lround(rf)); if (i < 0) { hit.kind = HitKind::NewLine; hit.row = i; hit.col = col_From_Z(); return hit; } // prepend if (i >= N) { hit.kind = HitKind::NewLine; hit.row = i; hit.col = col_From_Z(); return hit; } // append const double row_y = _row0_y - static_cast(i) * lh; if (SDL_fabs(impact.y - row_y) > _he_y) { // in the inter-row gap → new line hit.kind = HitKind::NewLine; hit.row = (impact.y < row_y) ? i + 1 : i; // below this row → line after it hit.col = col_From_Z(); return hit; } // ── horizontal on row i: re-chunk the buffer line and apply 90/10 per token span ── hit.row = i; hit.line_idx = static_cast(i); const std::string& s = blines[static_cast(i)]; const double vcf = (_code_left_z - impact.z) / _char_w; // fractional visual column int vcol = 0; size_t b = 0; size_t k = 0; // Split exactly like relayout_Editing: each space/tab is one cell, and a // non-whitespace run is lexically split into word / operator-group / // single-punctuation sub-tokens. This keeps `k` in lock-step with // line.tokens, so a DropToken's token_idx points at the token actually // aimed at — previously a run was one coarse cell, so token_idx skewed and // the caller detached the wrong token (often whitespace, esp. in sandbox). auto is_word = [](char cc) { return (cc >= 'a' && cc <= 'z') || (cc >= 'A' && cc <= 'Z') || (cc >= '0' && cc <= '9') || cc == '_'; }; auto is_op = [](char cc) { for (const char* o = "+-*/%=<>!&|^~.:?"; *o; ++o) if (*o == cc) return true; return false; }; while (b < s.size()) { const char c = s[b]; int b0 = static_cast(b), b1, vc = vcol, vs; bool ws; if (c == ' ') { b1 = b0 + 1; vs = 1; ws = true; ++b; } else if (c == '\t') { b1 = b0 + 1; vs = 4 - (vcol%4); ws = true; ++b; } else { const size_t s0 = b; if (is_word(c)) { while (b < s.size() && is_word(s[b])) ++b; } else if (is_op(c)) { while (b < s.size() && is_op(s[b])) ++b; } else { ++b; } // single bracket / ; , quote … b1 = static_cast(b); vs = static_cast(b - s0); ws = false; } if (vcf >= vc && vcf < vc + vs) { if (ws) { hit.kind = HitKind::Insert; hit.col = b1; return hit; } // split: word after the space const double center = vc + vs * 0.5; if (SDL_fabs(vcf - center) <= 0.45 * vs) { // central 90% → drop hit.kind = HitKind::DropToken; hit.line_idx = static_cast(i); hit.token_idx = k; return hit; } hit.kind = HitKind::Insert; // outer 5% each side → beside hit.col = (vcf < center) ? b0 : b1; return hit; } vcol += vs; ++k; } // before the first column or past the end of the line hit.kind = HitKind::Insert; hit.col = (vcf < 0.0) ? 0 : static_cast(s.size()); return hit; } void Handler::attach_Editing(DynArray& block, const Vec3& impact) { if (!_vim) { block.clear(); return; } const EditorHit hit = classify_Impact(impact); std::string word; const Token::Data* prev = nullptr; for (const TokenPtr& w : block) { if (!w) continue; if (prev && !prev->is_Whitespace() && !w->is_Whitespace()) word += ' '; word += w->text; prev = w.get(); } block.clear(); if (word.empty()) return; // attach only inserts; the DropToken outcome is handled by the caller. _vim->insert_Word_At(hit.row, hit.col, word, /*new_line=*/hit.kind == HitKind::NewLine); _vim_seen_version = _vim->get_Version(); relayout_Editing(); } void Handler::mark_Dirty() { ++_gen; _dirty = true; } std::string Handler::render_Source() { std::string out; for (Line& line : _lines) { for (size_t i = 0; i < line.tokens.size(); i++) { Token::Data* t = line.tokens[i].get(); if (!t) continue; t->byte_begin = out.size(); out += t->text; t->byte_end = out.size(); } out += '\n'; } _last_src = out; _dirty = false; return out; } void Handler::apply_Highlight(const DynArray& spans, uint64_t gen) { if (gen != _gen) return; for (Line& line : _lines) { for (TokenPtr& tp : line.tokens) { Token::Data* t = tp.get(); if (!t || t->color_src != ColorSource::Lexed) continue; size_t best = 0; Token::Type type = Token::Type::Identifier; bool found = false; for (const HiSpan& s : spans) { size_t lo = (t->byte_begin > s.byte_begin) ? t->byte_begin : s.byte_begin; size_t hi = (t->byte_end < s.byte_end) ? t->byte_end : s.byte_end; if (lo < hi) { size_t ov = hi - lo; if (ov > best) { best = ov; type = s.type; found = true; } } } if (found) t->set_Context(Token::get_Color(type)); } } } Token::Data* Handler::token_At_Byte(size_t off) { for (Line& line : _lines) for (TokenPtr& t : line.tokens) if (t && off >= t->byte_begin && off < t->byte_end) return t.get(); return nullptr; } DynArray Handler::as_Lines() const { DynArray out; out.reserve(_lines.size()); for (const Line& line : _lines) { std::string s; for (const TokenPtr& t : line.tokens) if (t) s += t->text; out.push_back(std::move(s)); } return out; } void Handler::relayout() { if (_layout == LayoutMode::Editing) relayout_Editing(); else if (_layout == LayoutMode::FaceCamera) relayout_FaceCamera(); else relayout_Display(); } void Handler::relayout_Display() { const double x_wall = _transform.position.x; const double lh = _line_height; const double gap = 0.5; const double floor_y = World::get_FloorY(); size_t n_lines = 0; for (const Line& l : _lines) if (!l.is_Empty()) ++n_lines; if (n_lines == 0) return; const double bottom_y = floor_y + 1.2; double y = bottom_y + static_cast(n_lines - 1) * lh; double max_width = 0.0; for (Line& l : _lines) { if (l.is_Empty()) continue; double total_w = 0.0; for (TokenPtr& t : l.tokens) { t->yaw = _transform.yaw; total_w += 2.0 * (double)t->half_extent; } total_w += gap * static_cast(l.tokens.size() - 1); max_width = SDL_max(max_width, total_w); l.y = y; y -= lh; } const double left_z = max_width * 0.5; for (Line& l : _lines) { if (l.is_Empty()) continue; double cursor = left_z; for (TokenPtr& t : l.tokens) { const double half = (double)t->half_extent; const double center_z = cursor - half; t->pos = Vec3{ x_wall, l.y, center_z }; cursor = center_z - half - gap; } } } void Handler::relayout_Editing() { if (!_vim) return; const auto& blines = _vim->get_Lines(); const double lh = _line_height; const int R = _vim->get_Row(); const int N = static_cast(blines.size()); const double ws = World::get_BoxScale(); const double bottom_y = -ws + 1.2; const double anchor_bottom = bottom_y + static_cast(N - 1) * lh; if (_capturing) { // vim-style scrolloff, zoom-aware: rows_half = how many rows fit between // the eye and the top edge at the current zoom; MARGIN can't exceed it, so // the first/last line stays in view (gg no longer loses line 1 off the top // when zoomed in). 0 → unknown → fall back to the cap. constexpr int MARGIN_MAX = 4; // cap when zoomed out (vim scrolloff feel) constexpr int PAD = 1; // blank rows kept at the file ends const int rows_half = (_view_half_h > 0.0) ? static_cast(SDL_floor(_view_half_h / lh)) : MARGIN_MAX; const int MARGIN = SDL_clamp(rows_half - PAD, 1, MARGIN_MAX); const int visible = 2 * MARGIN + 1; _scroll_top = SDL_clamp(R - MARGIN, -PAD, SDL_max(0, N - visible) + PAD); _row0_y = (_eye_y + static_cast(MARGIN) * lh) + static_cast(_scroll_top) * lh; } else { _row0_y = anchor_bottom; // at rest: whole file bottom-aligned to the floor } _char_w = editing_CharWidth(); _he_y = editing_HalfExtentY(); // Salvage old rows so unchanged lines keep their meshes (reuse only when the // line COUNT is unchanged — matches the original per-keystroke fast path). DynArray old = std::move(_lines); _lines.clear(); const bool can_reuse = (old.size() == blines.size()); DynArray old_text(old.size()); for (size_t i = 0; i < old.size(); i++) { std::string s; for (size_t j = 0; j < old[i].tokens.size(); ++j) { if (old[i].tokens[j]) s += old[i].tokens[j]->text; } old_text[i] = std::move(s); } // Pass 1: build tokens + gutter numbers; measure widest digit + longest line. struct BT { TokenPtr t; int vcol; int vspan; }; struct BR { DynArray toks; TokenPtr num_l; TokenPtr num_r; }; DynArray
built; built.reserve(blines.size()); double gutter_w = 0.0, block_code_w = 0.0; for (size_t i = 0; i < blines.size(); i++) { const std::string& s = blines[i]; block_code_w = SDL_max(block_code_w, _char_w * static_cast(s.size())); BR br; { const std::string s_no = std::to_string(i + 1); if (can_reuse && i < old.size() && old[i].num_left) br.num_l = std::move(old[i].num_left); else br.num_l = make_Number(s_no, _transform.position, _transform.yaw, 0.0022f); if (_both_columns) { if (can_reuse && i < old.size() && old[i].num_right) br.num_r = std::move(old[i].num_right); else br.num_r = make_Number(s_no, _transform.position, _transform.yaw, 0.0022f); } if (br.num_l) { set_NumberText(br.num_l.get(), s_no); gutter_w = SDL_max(gutter_w, 2.0 * (double)br.num_l->half_extent); } if (br.num_r) set_NumberText(br.num_r.get(), s_no); } const bool reuse_line = can_reuse && i < old.size() && old_text[i] == s; struct Chunk { std::string text; int vcol; int vspan; }; DynArray chunks; { int vcol = 0; size_t byte = 0; while (byte < s.size()) { const char c = s[byte]; if (c == ' ') { chunks.push_back({ " ", vcol, 1 }); vcol += 1; ++byte; } else if (c == '\t') { int span = 4 - (vcol % 4); chunks.push_back({ "\t", vcol, span }); vcol += span; ++byte; } else { // Lexically split the run so each identifier/number, operator group, // and punctuation char becomes its own token with its own colour. // (Previously the whole run was one token, so "a*2;", foo(int, or // ist"; coloured as a single unit / bled the neighbour's colour.) auto is_word = [](char cc) { return (cc >= 'a' && cc <= 'z') || (cc >= 'A' && cc <= 'Z') || (cc >= '0' && cc <= '9') || cc == '_'; }; auto is_op = [](char cc) { for (const char* o = "+-*/%=<>!&|^~.:?"; *o; ++o) if (*o == cc) return true; return false; }; std::string tk; const char c0 = s[byte]; if (is_word(c0)) { while (byte < s.size() && is_word(s[byte])) { tk += s[byte]; ++byte; } } else if (is_op(c0)) { while (byte < s.size() && is_op(s[byte])) { tk += s[byte]; ++byte; } } else { tk += c0; ++byte; } // single bracket / ; , quote … chunks.push_back({ tk, vcol, (int)tk.size() }); vcol += (int)tk.size(); } } block_code_w = SDL_max(block_code_w, _char_w * (double)vcol); // ← visual width, not s.size() } size_t token_idx = 0; for (const Chunk& ch : chunks) { TokenPtr t; if (reuse_line && token_idx < old[i].tokens.size()) { t = std::move(old[i].tokens[token_idx]); if (t && t->text != ch.text) t->retext(ch.text); // robust if the split shifts } else { t = make_Token(ch.text, Token::depth_wall, 0.003f, _transform.position, Config::Color::white); t->yaw = _transform.yaw; t->set_ColorSource(ColorSource::Lexed); } br.toks.push_back({ std::move(t), ch.vcol, ch.vspan }); ++token_idx; } built.push_back(std::move(br)); } const double gutter_gap = 0.6; const double code_u0 = gutter_w + gutter_gap; const double right_gutter = _both_columns ? (gutter_gap + gutter_w) : 0.0; const double block_w = code_u0 + block_code_w + right_gutter; const double z_block_left = block_w * 0.5; _code_left_z = z_block_left - code_u0; // Pass 2: position numbers + tokens on the grid. for (size_t i = 0; i < built.size(); i++) { const double row_y = _row0_y - static_cast(i) * lh; Line ln; ln.y = row_y; ln.line_height = lh; ln.number = static_cast(i) + 1; ln.show_number = true; if (built[i].num_l) { const double he = (double)built[i].num_l->half_extent; built[i].num_l->pos = _transform.position; built[i].num_l->pos.y = row_y; built[i].num_l->pos.z = z_block_left - he; ln.num_left = std::move(built[i].num_l); } if (built[i].num_r) { const double he = (double)built[i].num_r->half_extent; built[i].num_r->pos = _transform.position; built[i].num_r->pos.y = row_y; built[i].num_r->pos.z = (z_block_left - block_w) + he; // just inside right edge ln.num_right = std::move(built[i].num_r); } for (BT& bt : built[i].toks) { const double left_z = _code_left_z - (double)bt.vcol * _char_w; const double tok_w = _char_w * (double)bt.vspan; bt.t->pos = _transform.position; bt.t->pos.y = row_y; bt.t->pos.z = left_z - tok_w * 0.5; // Uniform editingspace cell for collision/aim: EVERY token (not just // whitespace) gets a cell-width × cap-height box. The projectile // intercept tests shape.half_extents (via aabb_Min/Max), so any token // keeping its ink extents ("," tiny, "M" wide, "g" tall with descender) // made the aim AABBs drift off the visible editingspace columns and rows. // Width = one cell; height = the cap height of an 'A'/'M' (_he_y), the // same metric classify_Impact uses for its vertical row ownership. bt.t->half_extent = (float)(tok_w * 0.5); bt.t->half_extent_y = (float)_he_y; bt.t->shape.half_extents.x = tok_w * 0.5; bt.t->shape.half_extents.y = _he_y; // Embedded in the wall now → not "loose in the world", so no bloom. // Whitespace tokens are constructed with glow on (a glowing ghost while // carried / in flight); clear it once they're placed so the editor wall // doesn't bloom the indentation. Re-enabled naturally on the next loose // token; placed tokens stay calm. bt.t->glow_enabled = false; ln.tokens.push_back(std::move(bt.t)); } _lines.push_back(std::move(ln)); } } void Handler::maintain_Numbers(double dt, const Camera&) { if (_lines.empty()) return; DynArray order; order.reserve(_lines.size()); for (size_t i = 0; i < _lines.size(); i++) if (!_lines[i].is_Empty()) order.push_back(i); if (order.empty()) return; std::sort(order.begin(), order.end(), [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; }); // Global code extents in z, so both columns align with a uniform gap. double code_left = -1e18, code_right = 1e18; for (size_t oi : order) for (TokenPtr& w : _lines[oi].tokens) { if (!w) continue; code_left = SDL_max(code_left, w->pos.z + (double)w->half_extent); code_right = SDL_min(code_right, w->pos.z - (double)w->half_extent); } double max_lh = 0.0, max_rh = 0.0; int rank = 1; for (size_t oi : order) { Line& l = _lines[oi]; const std::string s = std::to_string(rank); if (!l.num_left) l.num_left = make_Number(s, Vec3{_transform.position.x, l.y, 0.0}, _transform.yaw, Config::Wall::line_number_scale); if (_both_columns && !l.num_right) l.num_right = make_Number(s, Vec3{_transform.position.x, l.y, 0.0}, _transform.yaw, Config::Wall::line_number_scale); if (l.number != rank) { set_NumberText(l.num_left.get(), s); if (l.num_right) set_NumberText(l.num_right.get(), s); l.number = rank; } constexpr double STIFFNESS = 120.0, DAMPING = 12.0; l.bounce_vel += (-STIFFNESS * l.bounce_off - DAMPING * l.bounce_vel) * dt; l.bounce_off += l.bounce_vel * dt; max_lh = SDL_max(max_lh, (double)l.num_left->half_extent); if (l.num_right) max_rh = SDL_max(max_rh, (double)l.num_right->half_extent); ++rank; } const double ws = World::get_BoxScale(); const double EDGE = 0.3; double left_col_z = code_left + Config::Wall::line_number_gap; double right_col_z = code_right - Config::Wall::line_number_gap; left_col_z = SDL_min(left_col_z, ws - EDGE - 2.0 * max_lh); right_col_z = SDL_max(right_col_z, -ws + EDGE + 2.0 * max_rh); for (size_t oi : order) { Line& l = _lines[oi]; double x = _transform.position.x; for (TokenPtr& w : l.tokens) { if (w) { x = w->pos.x; break; } } const double ny = l.y + l.bounce_off; l.num_left->pos = Vec3{ x, ny, left_col_z + (double)l.num_left->half_extent }; if (l.num_right) l.num_right->pos = Vec3{ x, ny, right_col_z - (double)l.num_right->half_extent }; } } void Handler::apply_Tilt(const Camera& cam) { if (_face == FaceMode::None) return; // Phase 1 implements the wall case (Pitch toward eye). Yaw/Both (carried, // facecam labels) arrive with the TextLabel fold-in. if (_face != FaceMode::Pitch) return; auto tilt = [&](Token::Data* t) { if (t) t->pitch = Wall::tilt_TwardEye(t->pos, cam.pos); }; for (Line& l : _lines) { for (TokenPtr& t : l.tokens) tilt(t.get()); tilt(l.num_left.get()); tilt(l.num_right.get()); } } void Handler::update(double dt, const Camera& cam) { if (_layout == LayoutMode::FaceCamera) { facecam_Update(dt, cam); return; } if (_layout == LayoutMode::Display && _show_numbers) maintain_Numbers(dt, cam); apply_Tilt(cam); } void Handler::render(GlRenderer& gl, const Camera& cam, const Light& light) const { for (const Line& l : _lines) { for (const TokenPtr& t : l.tokens) if (t && !t->is_Whitespace()) t->draw(gl, cam, light); if (_show_numbers) { if (l.num_left) l.num_left ->draw(gl, cam, light); if (l.num_right) l.num_right->draw(gl, cam, light); } } if (_borrowed) for (const TokenPtr& t : *_borrowed) if (t) t->draw(gl, cam, light); } void Handler::scroll(double delta) { for (Line& l : _lines) { l.y += delta; for (TokenPtr& t : l.tokens) if (t) t->pos.y += delta; if (l.num_left) l.num_left ->pos.y += delta; if (l.num_right) l.num_right->pos.y += delta; } } DynArray Handler::number_Hitboxes() const { constexpr double PAD = 0.30; constexpr double DEPTH_X = 0.50; DynArray out; out.reserve(_lines.size() * 2); auto push_box = [&](const Token::Data* t, size_t i) { const double hz = (double)t->half_extent + PAD; const double hy = (double)t->half_extent_y + PAD; const Vec3 c = t->pos; out.push_back({ HIT_BASE + i, Vec3{ c.x - DEPTH_X, c.y - hy, c.z - hz }, Vec3{ c.x + DEPTH_X, c.y + hy, c.z + hz } }); }; for (size_t i = 0; i < _lines.size(); i++) { if (_lines[i].num_left) push_box(_lines[i].num_left.get(), i); if (_lines[i].num_right) push_box(_lines[i].num_right.get(), i); } return out; } double Handler::scroll_DeltaToEye(size_t line_idx, double eye_y) const { if (line_idx >= _lines.size()) return 0.0; return eye_y - _lines[line_idx].y; } void Handler::bounce(size_t line_idx) { if (line_idx < _lines.size()) _lines[line_idx].bounce_vel += Config::Wall::bounce_impulse; } Handler::EditingMetrics Handler::editing_Metrics() const { return { _char_w, _he_y, _code_left_z, _row0_y }; } double Handler::row_Y(int row) const { return _row0_y - static_cast(row) * _line_height; } void Handler::begin_Edit(double eye_y) { if (!_vim) _vim = cpputils::make_unique(); // editor persists one; guard anyway std::vector v; for (const std::string& s : as_Lines()) v.push_back(s); _vim->load(v); if (_vim->is_Empty()) _vim->start_Insert(); _vim_seen_version = _vim->get_Version(); _capturing = true; _eye_y = eye_y; _status.clear(); _layout = LayoutMode::Editing; relayout_Editing(); mark_Dirty(); } void Handler::end_Edit() { _capturing = false; _face = FaceMode::Pitch; // tilt toward the eye again at rest (flat only while editing) // Stay on the editing grid; the buffer persists as the source of truth. Recolor // to the lexed scheme (the connected view may have shown edit-time colours). for (Line& l : _lines) for (TokenPtr& t : l.tokens) if (t) t->set_ColorSource(ColorSource::Lexed); relayout_Editing(); mark_Dirty(); } void Handler::sync_VimFromLines() { if (!_vim) return; std::vector v; for (const std::string& s : as_Lines()) v.push_back(s); _vim->load(v); _vim_seen_version = _vim->get_Version(); } Handler::EditReq Handler::feed_Keys(DynArray& keys, double eye_y) { _eye_y = eye_y; if (!_vim) return EditReq::None; const int row_before = _vim->get_Row(); // detect motion-only changes (j/k/gg/G…) for (int k : keys) { if (k == -1) _vim->key_enter(); else if (k == -2) _vim->key_escape(); else if (k == -3) _vim->key_backspace(); else if (k == -4) _vim->key_tab(); else if (k == -5) _vim->key_redo(); else if (k > 0) _vim->key_char(static_cast(k)); } EditReq req = EditReq::None; switch (_vim->take_Signal()) { case VimBuffer::Signal::Exit: return EditReq::Exit; case VimBuffer::Signal::WriteQuit: return EditReq::WriteQuit; case VimBuffer::Signal::QuitDiscard: return EditReq::Discard; case VimBuffer::Signal::Write: req = EditReq::Write; break; case VimBuffer::Signal::Help: _status = { "-- VIM HELP --", "i a A o O insert Esc normal h j k l move", "0 $ line ends gg G :N jump x dd delete", "yy yw dw cw cc yank/change p P paste >> << indent", ":w write :q quit :wq write+quit :q! discard", }; break; default: break; } const bool edited = (_vim->get_Version() != _vim_seen_version); const bool moved = (_vim->get_Row() != row_before); if (edited || moved) { _vim_seen_version = _vim->get_Version(); relayout_Editing(); // re-anchor for scrolloff (motion) or rebuild (edit) if (edited) mark_Dirty(); // re-highlight only when text actually changed } return req; } void Handler::mark_Saved() { if (_vim) _vim->mark_Saved(); } int Handler::cursor_Row() const { return _vim ? _vim->get_Row() : 0; } int Handler::cursor_Col() const { return _vim ? _vim->get_Col() : 0; } int Handler::cursor_VisualCol() const { if (!_vim) return 0; const auto& lines = _vim->get_Lines(); const int r = _vim->get_Row(); const int bc = _vim->get_Col(); if (r < 0 || r >= (int)lines.size()) return bc; const std::string& L = lines[(size_t)r]; int vcol = 0; for (int i = 0; i < bc && i < (int)L.size(); i++) vcol += (L[(size_t)i] == '\t') ? (4 - (vcol % 4)) : 1; return vcol; } bool Handler::cursor_ToToken(Token::Data* t) { if (!_vim || !t) return false; for (size_t line_idx = 0; line_idx < _lines.size(); ++line_idx) { const auto& toks = _lines[line_idx].tokens; size_t col = 0; // byte column of token start for (size_t token_idx = 0; token_idx < toks.size(); ++token_idx) { if (toks[token_idx].get() == t) { _vim->set_Cursor((int)line_idx, (int)col); _vim_seen_version = _vim->get_Version(); relayout_Editing(); // cursor moved → re-anchor the view return true; } if (toks[token_idx]) col += toks[token_idx]->text.size(); // raw byte length (space/tab = 1) } } return false; } bool Handler::cursor_AtImpact(const Vec3& impact) { if (!_vim || _char_w <= 0.0) return false; const auto& blines = _vim->get_Lines(); const int N = static_cast(blines.size()); if (N == 0) return false; // row from height int row = static_cast(SDL_lround((_row0_y - impact.y) / _line_height)); row = SDL_clamp(row, 0, N - 1); // target visual column from horizontal position int vc = static_cast(SDL_lround((_code_left_z - impact.z) / _char_w)); if (vc < 0) vc = 0; // visual column → byte column (tabs span to the next multiple of 4) const std::string& s = blines[static_cast(row)]; int byte = 0, vcol = 0; while (byte < static_cast(s.size()) && vcol < vc) { vcol += (s[(size_t)byte] == '\t') ? (4 - (vcol % 4)) : 1; ++byte; } _vim->set_Cursor(row, byte); _vim_seen_version = _vim->get_Version(); relayout_Editing(); return true; } Handler::CursorHit Handler::resolve_Impact(const Vec3& impact) const { CursorHit h; if (!_vim || _char_w <= 0.0) return h; const auto& blines = _vim->get_Lines(); const int N = static_cast(blines.size()); if (N == 0) return h; int row = static_cast(SDL_lround((_row0_y - impact.y) / _line_height)); row = SDL_clamp(row, 0, N - 1); int vc = static_cast(SDL_lround((_code_left_z - impact.z) / _char_w)); if (vc < 0) vc = 0; const std::string& s = blines[static_cast(row)]; int byte = 0, vcol = 0; while (byte < static_cast(s.size()) && vcol < vc) { vcol += (s[(size_t)byte] == '\t') ? (4 - (vcol % 4)) : 1; ++byte; } h.row = row; h.byte = byte; h.ok = true; return h; } void Handler::place_CursorInsert(const CursorHit& hit) { if (!_vim || !hit.ok) return; _vim->enter_Insert(); // insert mode so the cursor can sit at end-of-line _vim->set_Cursor(hit.row, hit.byte); _vim_seen_version = _vim->get_Version(); relayout_Editing(); // re-anchor: scrolls this cursor into the player's view } VimBuffer::Mode Handler::cursor_Mode() const { return _vim ? _vim->get_Mode() : VimBuffer::Mode::Normal; } const std::string& Handler::cmdline_Text() const { static const std::string empty; return _vim ? _vim->get_Cmdline() : empty; } const DynArray& Handler::status_Lines() const { return _status; } std::string Handler::modeline() const { if (!_vim) return {}; if (_vim->get_Mode() == VimBuffer::Mode::Command) return _vim->get_Cmdline(); if (!_status.empty()) return _status[0]; if (!_vim->get_Status().empty()) return _vim->get_Status(); return (_vim->get_Mode() == VimBuffer::Mode::Insert) ? "-- INSERT --" : "-- NORMAL --"; } std::string Handler::vim_ShowCmd() const { return _vim ? _vim->get_ShowCmd() : std::string{}; } void Handler::set_Style(SDL_FColor color, float depth, float scale) { _style_color = color; _depth = depth; _scale = scale; } void Handler::set_Centre(const Vec3& p) { _layout = LayoutMode::FaceCamera; _pos_mode = PositionMode::Absolute; _centre = p; _anchor_ptr = nullptr; } void Handler::attach_To(const Vec3* anchor, const Vec3& offset) { _layout = LayoutMode::FaceCamera; _pos_mode = PositionMode::Anchor; _anchor_ptr = anchor; _attach_offset = offset; } void Handler::detach() { _pos_mode = PositionMode::Absolute; _anchor_ptr = nullptr; } void Handler::set_Text(const std::string& text) { if (text == _label_text && !_lines.empty()) return; _label_text = text; _layout = LayoutMode::FaceCamera; relayout_FaceCamera(); } void Handler::set_Text(const std::string& text, SDL_FColor color, float depth, float scale) { set_Style(color, depth, scale); _label_text.clear(); set_Text(text); } void Handler::set_Collidable(bool on) { _collidable = on; if (_lines.empty()) return; for (TokenPtr& w : _lines[0].tokens) { if (!w) continue; w->solid = on; w->carriable = false; w->interactable = false; w->body.mode = on ? PhysicsMode::Static : PhysicsMode::Hover; } } const DynArray& Handler::get_Words() const { static const DynArray empty; return _lines.empty() ? empty : _lines[0].tokens; } Vec3 Handler::label_Centre() const { if (_pos_mode == PositionMode::Anchor && _anchor_ptr) return { _anchor_ptr->x + _attach_offset.x, _anchor_ptr->y + _attach_offset.y, _anchor_ptr->z + _attach_offset.z }; return _centre; } void Handler::relayout_FaceCamera() { _lines.clear(); _label_off.clear(); DynArray toks; { std::istringstream ss(_label_text); std::string w; while (ss >> w) toks.push_back(w); } if (toks.empty()) return; DynArray built; built.reserve(toks.size()); for (const std::string& tk : toks) built.push_back(make_Token(tk, _depth, _scale, Vec3{0,0,0}, _style_color)); float total_w = 0.0f; for (const TokenPtr& t : built) total_w += t->half_extent * 2.0f + Config::Text3d::word_gap; total_w -= Config::Text3d::word_gap; Line line; float x_cur = -total_w * 0.5f; for (TokenPtr& t : built) { const float he = t->half_extent; _label_off.push_back(static_cast(x_cur + he)); t->carriable = false; t->interactable = false; t->solid = _collidable; t->body.mode = _collidable ? PhysicsMode::Static : PhysicsMode::Hover; t->body.restitution = 0.55; t->jiggle_off = Vec3{}; t->jiggle_vel = Vec3{}; line.tokens.push_back(std::move(t)); x_cur += he * 2.0f + Config::Text3d::word_gap; } _lines.push_back(std::move(line)); } void Handler::facecam_Update(double dt, const Camera& cam) { if (_lines.empty()) return; DynArray& words = _lines[0].tokens; const Vec3 centre = label_Centre(); Vec3 right { 1, 0, 0 }; float yaw_g { 0.0f }; float pitch_g { 0.0f }; if (_facecam) { Vec3 fwd = cam.pos - centre; double flen = Math::length(fwd); if (flen < 0.0001) return; fwd = fwd / flen; right = Math::cross(Vec3{0, 1, 0}, fwd); double rlen = Math::length(right); if (rlen < 0.0001) return; right = right / rlen; yaw_g = SDL_atan2f(static_cast(fwd.x), static_cast(fwd.z)); pitch_g = -SDL_asinf (static_cast(fwd.y)); } else { yaw_g = _face_yaw; pitch_g = 0.0f; right = { SDL_cos(_face_yaw), 0.0, -SDL_sin(_face_yaw) }; } auto clamp = [](double v, double lo, double hi) { return v < lo ? lo : (v > hi ? hi : v); }; for (size_t i = 0; i < words.size() && i < _label_off.size(); i++) { Token::Data* t = words[i].get(); if (!t || !t->loaded) continue; const double off = _label_off[i]; const Vec3 anchor { centre.x + right.x * off, centre.y + right.y * off, centre.z + right.z * off, }; Vec3& o = t->jiggle_off; Vec3& v = t->jiggle_vel; const Vec3 acc { -Config::Text3d::jiggle_stiff * o.x - Config::Text3d::jiggle_damp * v.x, -Config::Text3d::jiggle_stiff * o.y - Config::Text3d::jiggle_damp * v.y, -Config::Text3d::jiggle_stiff * o.z - Config::Text3d::jiggle_damp * v.z, }; v.x += acc.x * dt; v.y += acc.y * dt; v.z += acc.z * dt; o.x += v.x * dt; o.y += v.y * dt; o.z += v.z * dt; o.x = clamp(o.x, -Config::Text3d::jiggle_max_off, Config::Text3d::jiggle_max_off); o.y = clamp(o.y, -Config::Text3d::jiggle_max_off, Config::Text3d::jiggle_max_off); o.z = clamp(o.z, -Config::Text3d::jiggle_max_off, Config::Text3d::jiggle_max_off); t->pos = { anchor.x + o.x, anchor.y + o.y, anchor.z + o.z }; t->yaw = yaw_g; t->pitch = pitch_g; } } void Handler::kick(size_t word_index, const Vec3& projectile_vel, const Vec3& normal) { if (_lines.empty()) return; DynArray& words = _lines[0].tokens; if (word_index >= words.size() || !words[word_index]) return; double v_into = -(projectile_vel.x * normal.x + projectile_vel.y * normal.y + projectile_vel.z * normal.z); if (v_into < 0.0) v_into = 0.0; const double mag = v_into * Config::Text3d::kick_strength; Vec3& v = words[word_index]->jiggle_vel; v.x += -normal.x * mag; v.y += -normal.y * mag; v.z += -normal.z * mag; } } // namespace Text } // namespace Nimbin