Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / src/text/handler.cpp

sdl_runtime_compiler git · main

SDL3 game for running and compiling code at runtime

sdl3 c++ compiler dlopen cmake · first commit 2026-04-19 · last commit 2026-07-03 (3 months ago) · synced 3 days ago · upstream: git.ide3.de/hsnr/sdl-runtime-compiler

C++ 72.3% C 26.2%
git clone https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler.gitwget https://git.christianimmanuel.de/sdl-graphics/sdl_runtime_compiler/archive/sdl_runtime_compiler.tar.gz
src/text/handler.cpp 52 KB · 1654 lines raw
#include <SDL3/SDL.h>
#include <algorithm>
#include <sstream>
#include <string>

#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<double>(m2->half_extent) - 2.0 * static_cast<double>(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<double>(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<VimBuffer>();
        _eye_y = World::get_FloorY<double>() + 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<std::string>& 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<std::string> 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<TokenPtr> block)
{
    DynArray<TokenPtr> 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<std::ptrdiff_t>(i));
                if (l.tokens.empty())
                    _lines.erase(_lines.begin() + static_cast<std::ptrdiff_t>(line_idx));
                mark_Dirty();
                if (_vim) { sync_VimFromLines(); relayout_Editing(); }   // keep buffer in step
                return out;
            }
        }
    }
    return nullptr;
}


void
Handler::attach(DynArray<TokenPtr> 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<TokenPtr>& out)
{
    const double hover = World::get_FloorY<double>() + 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<std::string>{});
        _vim_seen_version = _vim->get_Version();
    }
    mark_Dirty();
}


Handler::Target
Handler::resolve_Target(double y) const
{
    DynArray<size_t> 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<std::ptrdiff_t>(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<std::ptrdiff_t>(i + 1),
                            std::move(sp));
            i += 2;                             // step past the word and the new space
        } else {
            ++i;
        }
    }
}


void
Handler::attach_Display(DynArray<TokenPtr>& 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<std::ptrdiff_t>(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<int>(blines.size());
    if (n == 0) return false;

    int r = static_cast<int>(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<size_t>(r)];
    int c = static_cast<int>(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<int>(blines.size());
    const double lh     = _line_height;

    auto col_From_Z = [&]() -> int {
        int c = static_cast<int>(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<int>(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<double>(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<size_t>(i);
    const std::string& s = blines[static_cast<size_t>(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<int>(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<int>(b); vs = static_cast<int>(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<size_t>(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<int>(s.size());
    return hit;
}


void
Handler::attach_Editing(DynArray<TokenPtr>& 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<HiSpan>& 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<std::string>
Handler::as_Lines() const
{
    DynArray<std::string> 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<double>();

    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<double>(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<double>(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<int>(blines.size());
    const double ws     = World::get_BoxScale();
    const double bottom_y = -ws + 1.2;

    const double anchor_bottom = bottom_y + static_cast<double>(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<int>(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<double>(MARGIN) * lh)
                + static_cast<double>(_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<Line> old = std::move(_lines);
    _lines.clear();
    const bool can_reuse = (old.size() == blines.size());

    DynArray<std::string> 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<BT> toks; TokenPtr num_l; TokenPtr num_r; };
    DynArray<BR> 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<double>(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<Chunk> 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<double>(i) * lh;

        Line ln;
        ln.y           = row_y;
        ln.line_height = lh;
        ln.number      = static_cast<int>(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<size_t> 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::NumberHit>
Handler::number_Hitboxes() const
{
    constexpr double PAD     = 0.30;
    constexpr double DEPTH_X = 0.50;

    DynArray<NumberHit> 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<double>(row) * _line_height;
}


void
Handler::begin_Edit(double eye_y)
{
    if (!_vim) _vim = cpputils::make_unique<VimBuffer>();   // editor persists one; guard anyway

    std::vector<std::string> 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<std::string> 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<int>& 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<char>(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<int>(blines.size());
    if (N == 0) return false;

    // row from height
    int row = static_cast<int>(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<int>(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<size_t>(row)];
    int byte = 0, vcol = 0;
    while (byte < static_cast<int>(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<int>(blines.size());
    if (N == 0) return h;

    int row = static_cast<int>(SDL_lround((_row0_y - impact.y) / _line_height));
    row = SDL_clamp(row, 0, N - 1);

    int vc = static_cast<int>(SDL_lround((_code_left_z - impact.z) / _char_w));
    if (vc < 0) vc = 0;

    const std::string& s = blines[static_cast<size_t>(row)];
    int byte = 0, vcol = 0;
    while (byte < static_cast<int>(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<std::string>&
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<TokenPtr>&
Handler::get_Words() const
{
    static const DynArray<TokenPtr> 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<std::string> toks;
    {
        std::istringstream ss(_label_text);
        std::string w;
        while (ss >> w) toks.push_back(w);
    }
    if (toks.empty()) return;

    DynArray<TokenPtr> 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<double>(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<TokenPtr>& 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<float>(fwd.x), static_cast<float>(fwd.z));
        pitch_g = -SDL_asinf (static_cast<float>(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<TokenPtr>& 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