Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 8973ded

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

Add focus wall jump And cursor to crosshair pos With Claude

Christian Immanuel · 2026-06-11 23:08 · 8973dedd9b41d81ac79fb1bff5c66e1f7b2f42e9

 src/states/game_state.cpp       | 187 ++++++++++++++++++++-
 src/states/game_state.hpp       |  17 ++
 src/systems/input_system.cpp    |   2 +
 src/systems/input_system.hpp    |   1 +
 src/systems/pick_system.cpp     |  29 +++-
 src/systems/pick_system.hpp     |   3 +
 src/systems/pult_system.cpp     |   2 +-
 src/systems/wall_connection.cpp |  29 ++--
 src/text/cpp_highlight.cpp      |  42 ++---
 src/text/handler.cpp            | 350 +++++++++++++++++++++++++++++++++-------
 src/text/handler.hpp            |  36 +++--
 src/text/token.cpp              |  48 +++++-
 src/text/token.hpp              |   5 +
 src/text/token_db.cpp           |   4 +
 src/text/vim_buffer.cpp         |  25 ++-
 src/text/vim_buffer.hpp         |  71 +++++---
 16 files changed, 705 insertions(+), 146 deletions(-)

diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index c4d579c..a39e0f5 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -50,7 +50,7 @@ GameState::GameState(size_t starting_level)
     _wall_labels     = Wall::Label::generate_All(World::get_BoxScale());
 
     _editor = Text::Handler(Wall::Role::Editor, World::get_BoxScale(),
-                            Text::LayoutMode::Snapped);
+                            Text::LayoutMode::Mono);
     _editor.set_ShowNumbers(true, true);
 
     _text_walls = { &_editor };
@@ -194,6 +194,25 @@ GameState::pult_DispatchAction(PultAction act)
 void
 GameState::on_Click()
 {
+    if (_editor.edit_Active()) {
+        const Wall::Transform tf =
+            Wall::get_Transform(Wall::Role::Editor, World::get_BoxScale());
+        const double wx = tf.position.x;
+        const Vec3&  o  = _ctx.camera->pos;
+        const Vec3&  d  = _ctx.camera->fwd;
+
+        if (SDL_fabs(d.x) > 1e-6) {
+            const double t = (wx - o.x) / d.x;
+            if (t > 0.0) {
+                Vec3 impact { wx, o.y + d.y * t, o.z + d.z * t };
+                if (_editor.cursor_AtImpact(impact)) return;
+            }
+        }
+        if (Text::Token::Data* tk = PickSystem::pick_WallToken(*_ctx.camera, _editor))
+            _editor.cursor_ToToken(tk);
+        return;
+    }
+
     PickResult r = PickSystem::pick(*_ctx.camera, _ctx.words, _ctx.scenery, _text_walls);
     if (!r.hit()) return;
 
@@ -204,6 +223,7 @@ GameState::on_Click()
     }
 }
 
+
 bool
 GameState::on_Pick()
 {
@@ -366,6 +386,13 @@ GameState::on_DebugPrint()
 void
 GameState::on_Jump()
 {
+    if (_focus_active) {
+        _ctx.player->pos       = _focus_pos;
+        _ctx.player->prev_pos  = _focus_pos;
+        _ctx.player->vel       = Vec3{0, 0, 0};
+        _ctx.player->on_ground = false;
+        return;
+    }
     if (_ctx.player->on_ground) {
         _ctx.player->vel.y     = ConfigPhysics::player_jump_velocity;
         _ctx.player->on_ground = false;
@@ -524,16 +551,36 @@ GameState::projectile_LandOnWallText(ProjectileResult& result)
         const size_t ti  =  rel        & 0x3FFF;
         Text::Handler* hw = (h < _text_walls.size()) ? _text_walls[h] : nullptr;
 
-        if (hw) {
+                if (hw) {
             const auto& lines = hw->get_Lines();
             if (li < lines.size() && ti < lines[li].tokens.size() && lines[li].tokens[ti]) {
-                if (hw->edit_Active()) {
-                    const Vec3 impact = lines[li].tokens[ti]->pos;
-                    DynArray<Text::TokenPtr> block;
-                    for (Text::TokenPtr& w : result.words) if (w) block.push_back(std::move(w));
-                    hw->attach(std::move(block), impact);
+
+                if (hw->is_Mono()) {
+                    Vec3 impact = lines[li].tokens[ti]->pos;
+                    for (const Text::TokenPtr& w : result.words)
+                        if (w) { impact = w->pos; break; }
+
+                    const Text::Handler::EditorHit hit = hw->classify_Impact(impact);
+
+                    if (hit.kind == Text::Handler::HitKind::DropToken) {
+                        const auto& ls = hw->get_Lines();
+                        if (hit.li < ls.size() && hit.ti < ls[hit.li].tokens.size() && ls[hit.li].tokens[hit.ti]) {
+                            if (Text::TokenPtr orphan = hw->detach(ls[hit.li].tokens[hit.ti].get())) {
+                                orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
+                                orphan->yaw               = 0.0f;
+                                _ctx.drop_WordToFloor(std::move(orphan), Vec3{0.0, 1.5, 0.0});
+                            }
+                        }
+                        for (Text::TokenPtr& w : result.words)
+                            if (w) _ctx.drop_WordToFloor(std::move(w), result.vel);
+                    } else {
+                        DynArray<Text::TokenPtr> block;
+                        for (Text::TokenPtr& w : result.words) if (w) block.push_back(std::move(w));
+                        hw->attach(std::move(block), impact);
+                    }
                     return;
                 }
+
                 if (Text::TokenPtr orphan = hw->detach(lines[li].tokens[ti].get())) {
                     orphan->body.hover_height = World::get_FloorY<double>() + 1.5;
                     orphan->yaw               = 0.0f;
@@ -570,6 +617,8 @@ GameState::handle_Event(Game& game, const SDL_Event& event)
 void
 GameState::update(double dt, const GlRenderer* gl)
 {
+    if (gl) { _fov_y = gl->get_Fov_y(); _aspect = gl->get_Aspect(); }
+
     FrameInput& input = _frame_input;
 
     { FP_ZONE("update.input"      ); update_Input       (input, dt);}
@@ -655,12 +704,17 @@ GameState::update_Input(FrameInput& input, double dt)
     if (input.shoot && _shoot_cooldown <= 0.0)
         { FP_ZONE("handle_input.onShootO"); if (on_ShootOne()) _shoot_cooldown = 0.15; }
 
-    if (!_freelook_sys->state.active)
+    if (!_freelook_sys->state.active && !input.focus_wall)
         _ctx.player->add_Look(input.look_x, input.look_y);
 
+    if (input.focus_wall)
+        input.forward = input.backward = input.left = input.right = false;
+
     _freelook_sys->update(dt);
     _ctx.player->compute_Movement(input);
     _ctx.camera->sync_FromPlayer(*_ctx.player);
+
+    update_WallFocus(input, dt);
 }
 
 
@@ -1199,3 +1253,120 @@ GameState::on_EditorScroll(int8_t dt)
     if (dt > 0) _editor.scroll( ConfigWall::scroll_step);
     else        _editor.scroll(-ConfigWall::scroll_step);
 }
+
+
+Text::Handler*
+GameState::handler_For(Wall::Role r)
+{
+    switch (r) {
+        case Wall::Role::Editor:   return &_editor;
+        case Wall::Role::Program:  return &_program;
+        case Wall::Role::Compiler: return &_compiler;
+        case Wall::Role::Info:     return &_info;
+    }
+    return nullptr;
+}
+
+
+bool
+GameState::compute_FocusTarget()
+{
+    const Camera& cam = *_ctx.camera;
+    const float  ws  = World::get_BoxScale<float>();
+
+    // Pick the wall plane the camera faces (nearest forward intersection).
+    const Wall::Role roles[4] = { Wall::Role::Editor, Wall::Role::Program,
+                                  Wall::Role::Info,   Wall::Role::Compiler };
+    Wall::Role best_role = Wall::Role::Editor;
+    bool   found  = false;
+    double best_t = 1e30;
+    for (Wall::Role role : roles) {
+        const Vec3 n = Wall::inward_Normal(Wall::position_Of(role));
+        const Vec3 p = Wall::get_Position(role, ws);
+        const double denom = cam.fwd.x*n.x + cam.fwd.y*n.y + cam.fwd.z*n.z;
+        if (denom < -1e-4) {                                   // fwd opposes inward → facing it
+            const double t = ((p.x-cam.pos.x)*n.x + (p.y-cam.pos.y)*n.y + (p.z-cam.pos.z)*n.z) / denom;
+            if (t > 0.1 && t < best_t) { best_t = t; best_role = role; found = true; }
+        }
+    }
+    if (!found) return false;
+
+    const Wall::Transform tf = Wall::get_Transform(best_role, ws);
+    const Vec3 n = Wall::inward_Normal(tf.wall_position);
+    const bool horiz_is_z = (tf.wall_position == Wall::Position::PosX ||
+                             tf.wall_position == Wall::Position::NegX);
+
+    // Text bounding box (skip whitespace ghosts).
+    double y_min=1e30, y_max=-1e30, h_min=1e30, h_max=-1e30; bool any=false;
+    if (Text::Handler* hw = handler_For(best_role))
+        for (const Text::Line& line : hw->get_Lines())
+            for (const Text::TokenPtr& tp : line.tokens) {
+                if (!tp || tp->is_Whitespace()) continue;
+                const double hc = horiz_is_z ? tp->pos.z : tp->pos.x;
+                y_min = SDL_min(y_min, tp->pos.y - static_cast<double>(tp->half_extent_y));
+                y_max = SDL_max(y_max, tp->pos.y + static_cast<double>(tp->half_extent_y));
+                h_min = SDL_min(h_min, hc - static_cast<double>(tp->half_extent));
+                h_max = SDL_max(h_max, hc + static_cast<double>(tp->half_extent));
+                any = true;
+            }
+
+    double cy, ch, text_w, text_h;
+    if (any) { cy=(y_min+y_max)*0.5; ch=(h_min+h_max)*0.5; text_w=h_max-h_min; text_h=y_max-y_min; }
+    else     { cy=tf.position.y;     ch=0.0;               text_w=2.0;          text_h=2.0; }
+
+    // Fit distance so the text fills ~85% of the view (whichever axis binds).
+    const double fov      = (_fov_y  > 0.01f) ? static_cast<double>(_fov_y ) : (SDL_PI_D / 3.0);
+    const double asp      = (_aspect > 0.01f) ? static_cast<double>(_aspect) : 1.7;
+    const double half_tan = SDL_tan(fov * 0.5);
+    constexpr double FIT  = 0.85;
+    const double d_v = (text_h * 0.5) / (FIT * half_tan);
+    const double d_h = (text_w * 0.5) / (FIT * half_tan * asp);
+    const double d   = SDL_max(SDL_max(d_v, d_h), 2.0);
+
+    Vec3 centroid = tf.position;            // wall-coordinate on the normal axis
+    centroid.y = cy;
+    if (horiz_is_z) centroid.z = ch; else centroid.x = ch;
+
+    _focus_pos     = { centroid.x + n.x*d, centroid.y + n.y*d, centroid.z + n.z*d };
+    _focus_yaw_r   = tf.yaw;                // facing the wall
+    _focus_pitch_r = 0.0;
+    return true;
+}
+
+
+void
+GameState::update_WallFocus(FrameInput& input, double dt)
+{
+    constexpr double DUR = 0.25;
+
+    if (input.focus_wall) {
+        if (!_focus_active) { if (!compute_FocusTarget()) return; _focus_active = true; }
+        _focus_t = SDL_min(1.0, _focus_t + dt / DUR);
+    } else {
+        if (!_focus_active) return;
+        _focus_t -= dt / DUR;
+        if (_focus_t <= 0.0) { _focus_t = 0.0; _focus_active = false; return; }
+    }
+
+    Camera& cam = *_ctx.camera;             // cam currently holds the fresh player view
+    const double s = _focus_t * _focus_t * (3.0 - 2.0 * _focus_t);   // smoothstep
+
+    double dyaw = _focus_yaw_r - cam.yaw_r;                  // shortest angular path
+    while (dyaw >  SDL_PI_D) dyaw -= 2.0 * SDL_PI_D;
+    while (dyaw < -SDL_PI_D) dyaw += 2.0 * SDL_PI_D;
+
+    const double yaw_r   = cam.yaw_r   + dyaw * s;
+    const double pitch_r = cam.pitch_r + (_focus_pitch_r - cam.pitch_r) * s;
+    const Vec3   pos     = { cam.pos.x + (_focus_pos.x - cam.pos.x) * s,
+                             cam.pos.y + (_focus_pos.y - cam.pos.y) * s,
+                             cam.pos.z + (_focus_pos.z - cam.pos.z) * s };
+
+    const double binrad = ConfigPhysics::binrad;
+    cam.pos     = pos;
+    cam.yaw_r   = yaw_r;
+    cam.pitch_r = pitch_r;
+    cam.yaw     = (uint32_t)SDL_lround(yaw_r   / binrad);
+    cam.pitch   = (int32_t) SDL_lround(pitch_r / binrad);
+    cam.fwd     = { -SDL_sin(yaw_r)*SDL_cos(pitch_r), SDL_sin(pitch_r), -SDL_cos(yaw_r)*SDL_cos(pitch_r) };
+    cam.right   = {  SDL_cos(yaw_r), 0.0, -SDL_sin(yaw_r) };
+}
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 4f98762..042d05b 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -59,6 +59,17 @@ class GameState : public IState
     double _pick_cooldown     { 0.0 };
     double _shoot_cooldown    { 0.0 };
 
+    // focus wall jump START
+    bool   _focus_active  { false };
+    double _focus_t       { 0.0 };       // 0 = player view, 1 = wall view
+    Vec3   _focus_pos     {};
+    double _focus_yaw_r   { 0.0 };
+    double _focus_pitch_r { 0.0 };
+    float  _fov_y         { 0.0f };
+    float  _aspect        { 0.0f };
+    // focus wall jump END
+
+
     std::string        _typing_last;
 
     CarriedLine        _carried;
@@ -141,6 +152,12 @@ class GameState : public IState
     std::string sandbox_BackupPath   () const;
     // dynamic-lib sandbox END
 
+    // focus wall jump START
+    bool            compute_FocusTarget();
+    void            update_WallFocus(FrameInput& input, double dt);
+    Text::Handler*  handler_For(Wall::Role r);
+    // focus wall jump END
+
 public:
     explicit GameState    (size_t starting_level = 0);
             ~GameState    () = default;
diff --git a/src/systems/input_system.cpp b/src/systems/input_system.cpp
index ceebf0b..29b7857 100644
--- a/src/systems/input_system.cpp
+++ b/src/systems/input_system.cpp
@@ -80,6 +80,7 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
                 case SDLK_S: input.backward = true; break;
                 case SDLK_D: input.right    = true; break;
                 case SDLK_E: input.pick     = true; break;
+                case SDLK_X: input.focus_wall = true; break;
 
                 case SDLK_F1:     game.change_State(cpputils::make_unique<GameState>()); break;
                 case SDLK_F2:     input.commands.push_back(InputCommand::SandboxRestore);break;
@@ -112,6 +113,7 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
                 case SDLK_S: input.backward = false; break;
                 case SDLK_D: input.right    = false; break;
                 case SDLK_E: input.pick     = false; break;
+                case SDLK_X: input.focus_wall = false; break;
 
                 case SDLK_LCTRL: input.normal_keys = false; break;
 
diff --git a/src/systems/input_system.hpp b/src/systems/input_system.hpp
index 4fcf1a8..dc23808 100644
--- a/src/systems/input_system.hpp
+++ b/src/systems/input_system.hpp
@@ -35,6 +35,7 @@ struct FrameInput
     bool right    = false;
     bool pick     = false;
     bool shoot    = false;
+    bool focus_wall = false;     
 
     bool normal_keys    = false;
 
diff --git a/src/systems/pick_system.cpp b/src/systems/pick_system.cpp
index 7db92b7..d5d7812 100644
--- a/src/systems/pick_system.cpp
+++ b/src/systems/pick_system.cpp
@@ -10,7 +10,8 @@ static constexpr double MAX_REACH_XZ = 3.8;
 static constexpr double MAX_ANGLE    = 0.25;
 
 static double
-ang_score(const GameObject& obj, const Vec3& eye, const Vec3& fwd)
+ang_score(const GameObject& obj, const Vec3& eye, const Vec3& fwd,
+          double max_reach = MAX_REACH_XZ)
 {
     double he;
     if (obj.type == ObjectType::Text)
@@ -45,7 +46,7 @@ ang_score(const GameObject& obj, const Vec3& eye, const Vec3& fwd)
     Vec3 dv { closest.x - eye.x, closest.y - eye.y, closest.z - eye.z };
 
     double xz = SDL_sqrt(dv.x*dv.x + dv.z*dv.z);
-    if (xz > MAX_REACH_XZ) return -1.0;
+    if (xz > max_reach) return -1.0;
 
     double len = Math::length(dv);
     if (len < 0.001) return -1.0;
@@ -114,4 +115,28 @@ pick(const Camera& camera,
     return result;
 }
 
+
+Text::Token::Data*
+pick_WallToken(const Camera& camera, Text::Handler& wall)
+{
+    const Vec3& eye = camera.pos;
+
+    Text::Token::Data* best       = nullptr;
+    double             best_angle = MAX_ANGLE;
+
+    for (const Text::Line& line : wall.get_Lines()) {
+        for (const Text::TokenPtr& tp : line.tokens) {
+            Text::Token::Data* t = tp.get();
+            if (!t) continue;
+            
+            double a = ang_score(*t, eye, camera.fwd, /*max_reach=*/1e18);
+            if (a >= 0.0 && a < best_angle) {
+                best_angle = a;
+                best       = t;
+            }
+        }
+    }
+    return best;
+}
+
 }  // namespace PickSystem
diff --git a/src/systems/pick_system.hpp b/src/systems/pick_system.hpp
index 09063a1..f857185 100644
--- a/src/systems/pick_system.hpp
+++ b/src/systems/pick_system.hpp
@@ -26,4 +26,7 @@ namespace PickSystem
                     const DynArray<Text::TokenPtr>& words,
                     const DynArray<GameObjectPtr>&  scenery,
                     const DynArray<Text::Handler*>& walls);
+
+    Text::Token::Data*
+    pick_WallToken(const Camera& camera, Text::Handler& wall);
 }
diff --git a/src/systems/pult_system.cpp b/src/systems/pult_system.cpp
index 9185b5e..d5a81ad 100644
--- a/src/systems/pult_system.cpp
+++ b/src/systems/pult_system.cpp
@@ -17,7 +17,7 @@ PultSystem::init(DynArray<GameObjectPtr>& scenery,
     const double inset   = 3.0;
     const float  btn_dy  = Pult::H + 0.10f + 0.04f;
     const float  lane_1  = -5.5f;
-    const float  lane_2  =  0.0f;
+    const float  lane_2  =  8.5f;
     const float  lane_3  =  5.5f;
 
     auto make_pult_pos = [&](Wall::Role role, float along) -> Vec3
diff --git a/src/systems/wall_connection.cpp b/src/systems/wall_connection.cpp
index 92c3bd0..928319c 100644
--- a/src/systems/wall_connection.cpp
+++ b/src/systems/wall_connection.cpp
@@ -32,7 +32,7 @@ Connection::begin_Editor(const Vec3& pult_pos, const Text::Handler& editor)
 {
     _target   = Target::Editor;
     _pult_pos = pult_pos;
-    _editor   = &editor;           // Handler is edited in place; we only draw chrome
+    _editor   = &editor;           
     _cmd_last.clear();
     _cmdline.reset();
 }
@@ -81,7 +81,7 @@ Connection::rebuild_Cmdline(const std::string& text)
     _cmdline.reset();
     if (text.empty()) return;
 
-    // Built at origin; render() places it at the eased _cmd_pos each frame.
+    
     _cmdline = Text::make_Token(text, Text::Token::depth_wall, 0.0016f, Vec3{0,0,0}, ConfigColor::white);
 }
 
@@ -92,8 +92,8 @@ Connection::update(double dt, const Camera& cam, float aspect, float fov_y)
     if (!is_Active()) return;
     _blink += dt;
 
-    // Editor mode-line text comes from the Handler's vim state — rebuild the
-    // floating mesh only when it changes.
+    
+    
     if (_target == Target::Editor && _editor) {
         std::string ml = _editor->modeline();
         if (ml != _cmd_last) { rebuild_Cmdline(ml); _cmd_last = ml; }
@@ -105,9 +105,9 @@ Connection::update(double dt, const Camera& cam, float aspect, float fov_y)
     const double half_h = dist * static_cast<double>(SDL_tanf(fov_y * 0.5f));
     const double half_w = half_h * static_cast<double>(aspect);
 
-    // Sit a fraction of the way toward the lower-left frustum corner.
-    const double fx = 0.82;   // horizontal: 82% toward the left edge
-    const double fy = 0.78;   // vertical:   78% toward the bottom edge
+    
+    const double fx = 0.82;   
+    const double fy = 0.78;   
     const double left = half_w * fx;
     const double down = half_h * fy;
 
@@ -138,8 +138,8 @@ Connection::render(GlRenderer& gl, const Camera& cam, const Light& light) const
         draw_Electric     (gl, cam);
     }
     else if (_target == Target::Editor) {
-        // Tokens + line numbers are drawn by the Handler itself; we only add the
-        // editing chrome on top.
+        
+        
         if (_cmdline) {
             const double hw = static_cast<double>(_cmdline->half_extent);
             _cmdline->pos = Vec3{
@@ -191,7 +191,7 @@ Connection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& li
     if (SDL_fmod(_blink, 0.8) > 0.4) return;
 
     const Text::Handler::MonoMetrics m = _editor->mono_Metrics();
-    if (m.char_w <= 0.0) return;                 // no mono layout yet
+    if (m.char_w <= 0.0) return;                 
 
     const int    row   = _editor->cursor_Row();
     const int    col   = _editor->cursor_Col();
@@ -202,8 +202,13 @@ Connection::draw_EditorCursor(GlRenderer& gl, const Camera& cam, const Light& li
 
     Vec3 c { tf.position.x, row_y - (m.he_y + 0.06), cz };
 
+    
+    
+    const Vec3  ref   { tf.position.x, row_y, cz };
+    const float pitch = Wall::tilt_TwardEye(ref, cam.pos);
+
     Mat4 view      = buildViewMat4 (cam);
-    Mat4 get_Model = buildModelMat4(c, tf.yaw);
+    Mat4 get_Model = buildModelMat4(c, tf.yaw, pitch);
     Mat4 mvp       = Mat4::mul     (gl.get_Projection(), Mat4::mul(view, get_Model));
 
     gl.set_Wireframe(false);
@@ -252,4 +257,4 @@ Connection::draw_Electric(GlRenderer& gl, const Camera& cam) const
 }
 
 
-} // namespace Wall
+} 
diff --git a/src/text/cpp_highlight.cpp b/src/text/cpp_highlight.cpp
index 5bc5159..7ad8d63 100644
--- a/src/text/cpp_highlight.cpp
+++ b/src/text/cpp_highlight.cpp
@@ -17,9 +17,6 @@ thread_local TSParser* parser = [] {
     return p;
 }();
 
-// ---------------------------
-// Keyword / literal fast path
-// ---------------------------
 Text::Token::Type classify_Keyword(const std::string& w)
 {
     static const std::unordered_map<std::string, Text::Token::Type> kw = {
@@ -50,20 +47,17 @@ Text::Token::Type classify_Keyword(const std::string& w)
     return Text::Token::Type::Identifier;
 }
 
-// ---------------------------
-// Tree-sitter node classifier
-// ---------------------------
 bool classify_Node(TSNode node, Text::Token::Type& out)
 {
     const char* t = ts_node_type(node);
 
-    // comments
+
     if (std::strcmp(t, "comment") == 0) {
         out = Text::Token::Type::Comment;
         return true;
     }
 
-    // strings
+
     if (std::strcmp(t, "string_literal") == 0 ||
         std::strcmp(t, "raw_string_literal") == 0 ||
         std::strcmp(t, "char_literal") == 0 ||
@@ -72,7 +66,7 @@ bool classify_Node(TSNode node, Text::Token::Type& out)
         return true;
     }
 
-    // numbers
+
     if (std::strcmp(t, "number_literal") == 0 ||
         std::strcmp(t, "integer_literal") == 0 ||
         std::strcmp(t, "floating_point_literal") == 0) {
@@ -80,7 +74,7 @@ bool classify_Node(TSNode node, Text::Token::Type& out)
         return true;
     }
 
-    // types
+
     if (std::strcmp(t, "primitive_type") == 0 ||
         std::strcmp(t, "type_identifier") == 0 ||
         std::strcmp(t, "sized_type_specifier") == 0 ||
@@ -89,32 +83,32 @@ bool classify_Node(TSNode node, Text::Token::Type& out)
         return true;
     }
 
-    // namespaces
+
     if (std::strcmp(t, "namespace_identifier") == 0 ||
         std::strcmp(t, "namespace_definition") == 0) {
         out = Text::Token::Type::Namespace;
         return true;
     }
 
-    // fields
+
     if (std::strcmp(t, "field_identifier") == 0) {
         out = Text::Token::Type::Variable;
         return true;
     }
 
-    // operators / operator overloads
+
     if (std::strcmp(t, "operator_name") == 0) {
         out = Text::Token::Type::Function;
         return true;
     }
 
-    // preprocessor (keep tight, not prefix-based)
+
     if (std::strncmp(t, "preproc_", 8) == 0) {
         out = Text::Token::Type::Preprocessor;
         return true;
     }
 
-    // identifiers (context sensitive)
+
     if (std::strcmp(t, "identifier") == 0) {
         TSNode parent = ts_node_parent(node);
         const char* p = ts_node_is_null(parent) ? "" : ts_node_type(parent);
@@ -140,9 +134,6 @@ bool classify_Node(TSNode node, Text::Token::Type& out)
     return false;
 }
 
-// ---------------------------
-// Tree walk
-// ---------------------------
 void walk(TSNode node, DynArray<Text::HiSpan>& out)
 {
     Text::Token::Type type;
@@ -175,9 +166,6 @@ void walk(TSNode node, DynArray<Text::HiSpan>& out)
 
 } // namespace
 
-// ---------------------------
-// Public API
-// ---------------------------
 DynArray<Text::HiSpan>
 Text::CppHighlight::classify(const std::string& source)
 {
@@ -196,12 +184,16 @@ Text::CppHighlight::classify(const std::string& source)
     return spans;
 }
 
-// ---------------------------
-// Word classification (FIXED)
-// ---------------------------
 Text::Token::Type
 Text::CppHighlight::classify_Word(const std::string& word)
 {
+
+    if (!word.empty()) {
+        bool only_ws = true;
+        for (char c : word) if (c != ' ' && c != '\t') { only_ws = false; break; }
+        if (only_ws) return Text::Token::Type::Whitespace;
+    }
+
     static std::unordered_map<std::string, Text::Token::Type> cache;
     static std::mutex mtx;
 
@@ -212,7 +204,7 @@ Text::CppHighlight::classify_Word(const std::string& word)
             return it->second;
     }
 
-    // fast deterministic classification (NO tree-sitter here)
+
     Text::Token::Type type = classify_Keyword(word);
 
     {
diff --git a/src/text/handler.cpp b/src/text/handler.cpp
index 4a2d349..eead608 100644
--- a/src/text/handler.cpp
+++ b/src/text/handler.cpp
@@ -58,8 +58,8 @@ void
 set_NumberText(Token::Data* t, const std::string& s)
 {
     if (t->text == s) return;
-    t->retext(s);                          
-    t->set_Color   (ConfigColor::orange); 
+    t->retext(s);
+    t->set_Color   (ConfigColor::orange);
     t->set_ColorSource(ColorSource::Static);
 }
 
@@ -73,6 +73,15 @@ Handler::Handler(Wall::Role role, float world_scale, LayoutMode mode)
     _line_height  = ConfigWall::line_height;
     _word_spacing = ConfigWall::free_space_dist;
     _append_color = ConfigColor::dark_gray;
+
+    // The editor is always-Mono: it keeps a persistent buffer (its source of
+    // truth) even when not connected, and lays out on the mono grid at rest.
+    // One relayout here establishes _char_w before the first thrown word.
+    if (_layout == LayoutMode::Mono) {
+        _vim   = cpputils::make_unique<VimBuffer>();
+        _eye_y = World::get_FloorY<double>() + 2.0;
+        relayout_Mono();              // establishes _char_w; _face keeps its Pitch default (tilt at rest)
+    }
 }
 
 
@@ -127,9 +136,13 @@ Handler::set_Lines(const DynArray<std::string>& lines)
         ln.line_height  = _line_height;
         ln.word_spacing = _word_spacing;
 
-        std::istringstream in(text);
-        std::string tk;
-        while (in >> tk) {
+        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, ConfigColor::wall);
             t->yaw = _transform.yaw;
             t->set_ColorSource(src);
@@ -138,6 +151,15 @@ Handler::set_Lines(const DynArray<std::string>& lines)
         _lines.push_back(std::move(ln));
     }
 
+    // Editor (persistent buffer): make the buffer the authority for the mono
+    // 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();
 }
@@ -224,6 +246,7 @@ Handler::detach(Token::Data* t)
                 if (l.tokens.empty())
                     _lines.erase(_lines.begin() + static_cast<std::ptrdiff_t>(li));
                 mark_Dirty();
+                if (_vim) { sync_VimFromLines(); relayout_Mono(); }   // keep buffer in step
                 return out;
             }
         }
@@ -261,6 +284,11 @@ Handler::clear_ToFloor(DynArray<TokenPtr>& out)
         }
     }
     _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();
 }
 
@@ -362,6 +390,32 @@ Handler::apply_Target(const Target& t)
 }
 
 
+// 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_Mono 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, ConfigColor::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_Snapped(DynArray<TokenPtr>& block, const Vec3& impact)
 {
@@ -440,6 +494,10 @@ Handler::attach_Snapped(DynArray<TokenPtr>& block, const Vec3& impact)
         ++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);
 }
 
 
@@ -452,14 +510,16 @@ Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const
     if (n == 0) return false;
 
     int r = static_cast<int>(SDL_lround((_row0_y - impact.y) / _line_height));
-    r = SDL_clamp(r, 0, n);                 // n == one past the end == append a new row
+    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));
-    c = SDL_clamp(c, 0, static_cast<int>(line.size()));
+    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);
 
-    // Snap to the nearest inter-token gap so the shot never bisects a word.
     auto is_gap = [&](int i) {
         return i == 0 || i == (int)line.size()
             || line[(size_t)i] == ' ' || line[(size_t)(i - 1)] == ' ';
@@ -476,24 +536,94 @@ Handler::impact_To_RowCol(const Vec3& impact, int& row, int& col) const
 }
 
 
+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: 80% band owns the row, the 20% straddling each boundary inserts a line ──
+    const double rf   = (_row0_y - impact.y) / lh;
+    const int    i    = static_cast<int>(SDL_lround(rf));
+    const double frac = rf - static_cast<double>(i);
+
+    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
+    if (SDL_fabs(frac) > 0.4) {
+        hit.kind = HitKind::NewLine;
+        hit.row  = (frac > 0.0) ? i + 1 : i;          // gap between rows → new line there
+        hit.col  = col_From_Z();
+        return hit;
+    }
+
+    // ── horizontal on row i: re-chunk the buffer line and apply 80/20 per token span ──
+    hit.row = i; hit.li = 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;
+    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 { size_t s0 = b; while (b < s.size() && s[b] != ' ' && s[b] != '\t') ++b;
+               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.4 * vs) {                          // central 80% → drop
+                hit.kind = HitKind::DropToken; hit.li = static_cast<size_t>(i); hit.ti = k;
+                return hit;
+            }
+            hit.kind = HitKind::Insert;                                        // outer 20% → 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_Mono(DynArray<TokenPtr>& block, const Vec3& impact)
 {
     if (!_vim) { block.clear(); return; }
 
-    int row, col;
-    if (!impact_To_RowCol(impact, row, col)) { 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 (!word.empty()) word += ' ';
+        if (prev && !prev->is_Whitespace() && !w->is_Whitespace())
+            word += ' ';
         word += w->text;
+        prev = w.get();
     }
     block.clear();
     if (word.empty()) return;
 
-    _vim->insert_Word_At(row, col, word);
+    // 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_Mono();
@@ -516,7 +646,6 @@ Handler::render_Source()
         for (size_t i = 0; i < line.tokens.size(); ++i) {
             Token::Data* t = line.tokens[i].get();
             if (!t) continue;
-            if (i) out += ' ';
             t->byte_begin = out.size();
             out += t->text;
             t->byte_end = out.size();
@@ -574,10 +703,7 @@ Handler::as_Lines() const
     out.reserve(_lines.size());
     for (const Line& line : _lines) {
         std::string s;
-        for (size_t i = 0; i < line.tokens.size(); ++i) {
-            if (i) s += ' ';
-            if (line.tokens[i]) s += line.tokens[i]->text;
-        }
+        for (const TokenPtr& t : line.tokens) if (t) s += t->text;
         out.push_back(std::move(s));
     }
     return out;
@@ -650,7 +776,10 @@ Handler::relayout_Mono()
 
     const double anchor_cursor = _eye_y + static_cast<double>(R) * lh;
     const double anchor_bottom = bottom_y + static_cast<double>(N - 1) * lh;
-    _row0_y = SDL_min(anchor_cursor, anchor_bottom);
+    // While editing, follow the cursor (but don't let it float above the floor
+    // alignment). At rest, show the whole file bottom-aligned to the floor.
+    _row0_y = _capturing ? SDL_min(anchor_cursor, anchor_bottom)
+                         : anchor_bottom;
 
     _char_w = mono_CharWidth();
     _he_y   = mono_HalfExtentY();
@@ -665,15 +794,14 @@ Handler::relayout_Mono()
     for (size_t i = 0; i < old.size(); ++i) {
         std::string s;
         for (size_t j = 0; j < old[i].tokens.size(); ++j) {
-            if (j) s += ' ';
             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; size_t col; size_t len; };
-    struct BR { DynArray<BT> toks; TokenPtr num; };
+    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());
 
@@ -683,36 +811,53 @@ Handler::relayout_Mono()
         block_code_w = SDL_max(block_code_w, _char_w * static_cast<double>(s.size()));
 
         BR br;
-        if (can_reuse && i < old.size() && old[i].num_left)
-            br.num = std::move(old[i].num_left);
-        else
-            br.num = make_Number(std::to_string(i + 1), _transform.position, _transform.yaw, 0.0022f);
-
-        if (br.num) {
+        {
             const std::string s_no = std::to_string(i + 1);
-            set_NumberText(br.num.get(), s_no);
-            gutter_w = SDL_max(gutter_w, 2.0 * (double)br.num->half_extent);
+            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;
 
-        size_t pos = 0, ti = 0;
-        while (pos < s.size()) {
-            while (pos < s.size() && s[pos] == ' ') ++pos;
-            if (pos >= s.size()) break;
-            const size_t start = pos;
-            std::string  tk;
-            while (pos < s.size() && s[pos] != ' ') { tk += s[pos]; ++pos; }
+        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 { std::string tk;
+                     while (byte < s.size() && s[byte] != ' ' && s[byte] != '\t') { tk += s[byte]; ++byte; }
+                     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 ti = 0;
+        for (const Chunk& ch : chunks) {
             TokenPtr t;
             if (reuse_line && ti < old[i].tokens.size())
                 t = std::move(old[i].tokens[ti]);
             else {
-                t = make_Token(tk, Token::depth_wall, 0.003f, _transform.position, ConfigColor::white);
+                t = make_Token(ch.text, Token::depth_wall, 0.003f, _transform.position, ConfigColor::white);
                 t->yaw = _transform.yaw;
                 t->set_ColorSource(ColorSource::Lexed);
             }
-            br.toks.push_back({ std::move(t), start, tk.size() });
+            br.toks.push_back({ std::move(t), ch.vcol, ch.vspan });
             ++ti;
         }
         built.push_back(std::move(br));
@@ -720,7 +865,8 @@ Handler::relayout_Mono()
 
     const double gutter_gap  = 0.6;
     const double code_u0     = gutter_w + gutter_gap;
-    const double block_w     = code_u0 + block_code_w;
+    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;
 
@@ -734,20 +880,31 @@ Handler::relayout_Mono()
         ln.number      = static_cast<int>(i) + 1;
         ln.show_number = true;
 
-        if (built[i].num) {
-            const double he = (double)built[i].num->half_extent;
-            built[i].num->pos   = _transform.position;
-            built[i].num->pos.y = row_y;
-            built[i].num->pos.z = z_block_left - he;
-            ln.num_left = std::move(built[i].num);
+        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 - static_cast<double>(bt.col) * _char_w;
-            const double tok_w  = _char_w * static_cast<double>(bt.len);
+            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;
+            if (bt.t->is_Whitespace()) {
+                bt.t->half_extent        = (float)(tok_w * 0.5);
+                bt.t->shape.half_extents.x = tok_w * 0.5;
+            }
             ln.tokens.push_back(std::move(bt.t));
         }
 
@@ -862,7 +1019,7 @@ 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->draw(gl, cam, light);
+            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);
@@ -944,7 +1101,7 @@ Handler::row_Y(int row) const
 void
 Handler::begin_Edit(double eye_y)
 {
-    _vim = cpputils::make_unique<VimBuffer>();
+    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);
@@ -952,8 +1109,8 @@ Handler::begin_Edit(double eye_y)
     if (_vim->is_Empty()) _vim->start_Insert();
     _vim_seen_version = _vim->get_Version();
 
-    _eye_y = eye_y;
-    _face  = FaceMode::None;
+    _capturing = true;
+    _eye_y     = eye_y;
     _status.clear();
 
     _layout = LayoutMode::Mono;
@@ -965,20 +1122,31 @@ Handler::begin_Edit(double eye_y)
 void
 Handler::end_Edit()
 {
-    DynArray<std::string> lines;
-    if (_vim) for (const std::string& s : _vim->get_Lines()) lines.push_back(s);
-    _vim.reset();
-
-    _face = FaceMode::Pitch; _layout = LayoutMode::Snapped;
+    _capturing = false;
+    _face      = FaceMode::Pitch;     // tilt toward the eye again at rest (flat only while editing)
 
+    // Stay on the mono 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_Snapped();
+
+    relayout_Mono();
     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)
 {
@@ -1038,6 +1206,74 @@ 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 li = 0; li < _lines.size(); ++li) {
+        const auto& toks = _lines[li].tokens;
+        size_t col = 0;                                   // byte column of token start
+        for (size_t ti = 0; ti < toks.size(); ++ti) {
+            if (toks[ti].get() == t) {
+                _vim->set_Cursor((int)li, (int)col);
+                _vim_seen_version = _vim->get_Version();
+                relayout_Mono();                          // cursor moved → re-anchor the view
+                return true;
+            }
+            if (toks[ti]) col += toks[ti]->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_Mono();
+    return true;
+}
+
+
 VimBuffer::Mode
 Handler::cursor_Mode() const
 { return _vim ? _vim->get_Mode() : VimBuffer::Mode::Normal; }
diff --git a/src/text/handler.hpp b/src/text/handler.hpp
index 19808e9..708968a 100644
--- a/src/text/handler.hpp
+++ b/src/text/handler.hpp
@@ -64,6 +64,14 @@ public:
     Token::Data* token_At_Byte(size_t off);
     TokenPtr     detach       (Token::Data* t);
     void         attach       (DynArray<TokenPtr> tokens, const Vec3& impact);
+
+    
+    
+    
+    enum class HitKind : uint8_t { Insert, NewLine, DropToken };
+    struct EditorHit { HitKind kind; int row; int col; size_t li; size_t ti; };
+    EditorHit classify_Impact(const Vec3& impact) const;
+    bool      is_Mono() const { return _layout == LayoutMode::Mono; }
     void         clear_ToFloor(DynArray<TokenPtr>& out);
     double       scroll_DeltaToEye (size_t line_id, double eye_y) const;
 
@@ -81,28 +89,31 @@ public:
     void scroll (double delta);
 
 
-    // ── vim cursor support (Mono) ──────────────────────────────────────────
+    
     MonoMetrics mono_Metrics() const;
     double      row_Y       (int row) const;
 
-    // ── live editing (Mono) ───────────────────────────────────────────────
+    
     enum class EditReq : uint8_t { None, Write, WriteQuit, Exit, Discard };
 
     void    begin_Edit (double eye_y);
     void    end_Edit   ();
-    bool    edit_Active() const { return _layout == LayoutMode::Mono && _vim.get() != nullptr; }
+    bool    edit_Active() const { return _capturing; }
     EditReq feed_Keys  (DynArray<int>& keys, double eye_y);
     void    mark_Saved ();
 
-    int cursor_Row  () const;
-    int cursor_Col  () const;
+    int cursor_Row      () const;
+    int cursor_Col      () const;
+    int cursor_VisualCol() const;
+    bool cursor_ToToken (Token::Data* t);   
+    bool cursor_AtImpact(const Vec3& impact); 
     const std::string& cmdline_Text() const;
     std::string        modeline    () const;
     VimBuffer::Mode    cursor_Mode () const;
     const DynArray<std::string>&
                        status_Lines() const;
 
-    // ── billboard label ───────────────────────────────────────────────────
+    
     void set_Text      (const std::string& text);
     void set_Text      (const std::string& text, SDL_FColor color, float depth, float scale);
     void set_Centre    (const Vec3& p);
@@ -140,7 +151,7 @@ private:
     DynArray<std::string>     _status;
     const DynArray<TokenPtr>* _borrowed { nullptr };
 
-    size_t _view_tag { 0 };          // identity of currently-materialised info-wall content; 0 = none
+    size_t _view_tag { 0 };          
 
     bool _lex_enabled  { true };
     bool _show_numbers { false };
@@ -148,12 +159,12 @@ private:
     bool _bordered     { false };
     bool _underline    { false };
 
-    // lexing
+    
     bool        _dirty { false };
     uint64_t    _gen   { 0 };
     std::string _last_src;
 
-    // Mono grid cache (vim layout + cursor)
+    
     double _char_w     { 0.0 };
     double _he_y       { 0.0 };
     double _code_left_z{ 0.0 };
@@ -162,6 +173,7 @@ private:
 
     cpputils::unique_ptr<VimBuffer> _vim;
     unsigned                        _vim_seen_version { 0 };
+    bool                            _capturing { false };   
 
     enum class PositionMode : uint8_t { Absolute, Anchor };
     const Vec3*  _anchor_ptr    { nullptr };
@@ -179,12 +191,13 @@ private:
     void   billboard_Update  (double dt, const Camera& cam);
     Vec3   label_Centre      () const;
 
-    // layout
+    
     void   relayout       ();
     void   relayout_Snapped();
     void   relayout_Mono   ();
+    void   sync_VimFromLines();   
 
-    // Snapped placement internals
+    
     struct Target {
         enum   Kind { Merge, Insert } kind;
         size_t line_index;
@@ -195,6 +208,7 @@ private:
     Target resolve_Target  (double y) const;
     Line&  apply_Target    (const Target& t);
     void   attach_Snapped  (DynArray<TokenPtr>& block, const Vec3& impact);
+    void   ensure_WordSeparators(Line& l);
     void   attach_Mono     (DynArray<TokenPtr>& block, const Vec3& impact);
     void   maintain_Numbers(double dt, const Camera& cam);
     void   apply_Tilt      (const Camera& cam);
diff --git a/src/text/token.cpp b/src/text/token.cpp
index 47084d1..cde3f05 100644
--- a/src/text/token.cpp
+++ b/src/text/token.cpp
@@ -75,6 +75,29 @@ static SDL_FColor mix_col(SDL_FColor a, SDL_FColor b, float k)
 }
 
 
+// A whitespace token's text is a run of spaces or a tab. We keep the real text
+// (" " / "\t") for serialization, but build the MESH from an ASCII ghost glyph
+// so the token is visible as a tangible thing when it's loose in the world.
+// (The mesh path is single-byte, so the ghost must be ASCII — not "·"/"→".)
+static bool
+text_Is_Whitespace(const std::string& t, bool& has_tab)
+{
+    if (t.empty()) return false;
+    has_tab = false;
+    for (char c : t) {
+        if      (c == '\t') has_tab = true;
+        else if (c != ' ')  return false;
+    }
+    return true;
+}
+
+static std::string
+ghost_Glyph(const std::string& t)
+{
+    return (t.find('\t') != std::string::npos) ? ">" : ".";
+}
+
+
 namespace
 Token
 {
@@ -86,8 +109,22 @@ Data::Data(const std::string& txt,
                   true, true, true),
        text(txt), color(colr), depth(dpth), scale(scle)
 {
+    bool has_tab = false;
+    if (text_Is_Whitespace(text, has_tab)) {
+        is_tab   = has_tab;
+        is_space = !has_tab;
+    }
+
     single_color  = Text::Token::get_Color(CppHighlight::classify_Word(text));
     context_color = single_color;   // until a lexer overrides
+
+    if (is_Whitespace()) {
+        // Faint glyph that glows when loose in the world (bloomed ghost).
+        single_color  = SDL_FColor{ 0.45f, 0.55f, 0.70f, 1.0f };
+        context_color = single_color;
+        glow_enabled  = true;
+        glow_strength = 0.6f;
+    }
     // `color` (Static slot) already initialised to `colr` in the member init list.
     rebuild_Mesh();
 
@@ -149,6 +186,12 @@ Data::retext(const std::string& s)    // text changed → new single colour + ne
 {
     if (text == s) return;
     text = s;
+
+    bool has_tab = false;
+    const bool ws = text_Is_Whitespace(text, has_tab);
+    is_tab   = ws &&  has_tab;
+    is_space = ws && !has_tab;
+
     single_color = Text::Token::get_Color(CppHighlight::classify_Word(text));
     if (!has_context) context_color = single_color;
     rebuild_Mesh();
@@ -189,7 +232,10 @@ void
 Data::rebuild_Mesh()
 {
     // SPIKE("rebuild_mesh");
-    loaded = mesh.build(font_path, text, 64, depth, scale);
+    // Whitespace keeps its real text (" "/"\t") for serialization, but renders
+    // an ASCII ghost glyph so it's a visible thing when loose in the world.
+    const std::string build_str = is_Whitespace() ? ghost_Glyph(text) : text;
+    loaded = mesh.build(font_path, build_str, 64, depth, scale);
     if (!loaded) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
                      "Text::Token::Data: mesh build failed for \"%s\"", text.c_str());
diff --git a/src/text/token.hpp b/src/text/token.hpp
index ff05a7a..030cad1 100644
--- a/src/text/token.hpp
+++ b/src/text/token.hpp
@@ -41,6 +41,7 @@ Token
         StringLit,
         NumberLit,
         Namespace,
+        Whitespace,      // space / tab token — a layout gap on walls, a ghost glyph in-world
     };
 
     // token_db.cpp START
@@ -116,8 +117,12 @@ Token
         SDL_FColor  single_color {};             // classify_Word(text) — this word alone (Lint fallback)
         SDL_FColor  context_color {};            // lexer result inside a document (Lexed)
         bool        has_context { false };       // is context_color currently valid?
+        bool        is_space    { false };        // token text is a run of spaces
+        bool        is_tab      { false };        // token text contains a tab
         TextMesh    mesh;
 
+        bool is_Whitespace() const { return is_space || is_tab; }
+
         ColorSource color_src { ColorSource::Lint };
 
         float depth;
diff --git a/src/text/token_db.cpp b/src/text/token_db.cpp
index df11d47..1e562b2 100644
--- a/src/text/token_db.cpp
+++ b/src/text/token_db.cpp
@@ -122,6 +122,8 @@ get_Color(Type t)
         case Type::StringLit:   return ConfigColor::lime;
         case Type::NumberLit:   return ConfigColor::amber;
         case Type::Namespace:   return ConfigColor::teal;
+        case Type::Whitespace:  return SDL_FColor{ 0.45f, 0.55f, 0.70f, 1.0f };
+
     }
     return ConfigColor::white;
 }
@@ -144,6 +146,7 @@ generic_For(Type type)
         case Type::StringLit:    return { "String",       "A string literal" };
         case Type::NumberLit:    return { "Number",       "A numeric literal" };
         case Type::Namespace:    return { "Namespace",    "A namespace" };
+        case Type::Whitespace:   return { "WHitespace",   "A whitespace" };
     }
     return { "", "" };
 }
@@ -177,6 +180,7 @@ get_TypeName(Type t)
         case Type::StringLit:    return "String";
         case Type::NumberLit:    return "Number";
         case Type::Namespace:    return "Namespace";
+        case Type::Whitespace:   return "whitespace";
     }
     return "Unknown";
 }
diff --git a/src/text/vim_buffer.cpp b/src/text/vim_buffer.cpp
index c4509d6..864baff 100644
--- a/src/text/vim_buffer.cpp
+++ b/src/text/vim_buffer.cpp
@@ -87,22 +87,35 @@ VimBuffer::mark_Saved()
 // back out as its own token; the caller (Text::Handler::attach) has already
 // snapped `col` to an inter-token gap. Supports row == size() (append a row).
 void
-VimBuffer::insert_Word_At(int row, int col, const std::string& word)
+VimBuffer::insert_Word_At(int row, int col, const std::string& word, bool new_line)
 {
     if (word.empty()) return;
     if (_lines.empty()) _lines.push_back("");
 
     push_Undo();
 
-    if (row < 0) row = 0;
-    if (row >= (int)_lines.size()) {
-        _lines.resize((size_t)row + 1);   // pad with blank rows (at most one, by caller)
-        col = 0;
+    if (new_line) {
+        if (row < 0) {                                   // prepend |row| blank rows; word at new top
+            _lines.insert(_lines.begin(), (size_t)(-row), std::string());
+            row = 0;
+        } else if (row >= (int)_lines.size()) {          // append blanks down to row
+            _lines.resize((size_t)row + 1);
+        } else {                                         // insert a new blank row between existing rows
+            _lines.insert(_lines.begin() + row, std::string());
+        }
+    } else {
+        if (row < 0) row = 0;
+        if (row >= (int)_lines.size()) {
+            _lines.resize((size_t)row + 1);
+            col = 0;
+        }
     }
 
     std::string& L = _lines[(size_t)row];
     if (col < 0)            col = 0;
-    if (col > (int)L.size()) col = (int)L.size();
+    if (col > (int)L.size()) {
+        L.append((size_t)col - L.size(), ' ');
+    }
 
     // Add separating spaces only where the splice would otherwise fuse with
     // neighbouring text, so the new word stays a standalone token.
diff --git a/src/text/vim_buffer.hpp b/src/text/vim_buffer.hpp
index 2856784..232eced 100644
--- a/src/text/vim_buffer.hpp
+++ b/src/text/vim_buffer.hpp
@@ -22,43 +22,64 @@ public:
     void key_backspace();
     void key_escape   ();
     void key_tab      ();
-    void key_redo     ();
+    void key_redo     ();   // ctrl+r
 
     void   load       (const std::vector<std::string>& src);
     Signal take_Signal();
     void   mark_Saved ();
 
-
-    void   insert_Word_At(int row, int col, const std::string& word);
+    // Shoot-merge: splice a whole word into line `row` at character `col`,
+    // adding surrounding spaces so it tokenizes back out as its own token.
+    // `col` is expected pre-snapped to an inter-token gap by the caller
+    // (Text::Handler::attach). Clamps row/col, bumps version, marks dirty.
+    void   insert_Word_At(int row, int col, const std::string& word, bool new_line = false);
 
     bool is_Empty() const { return _lines.size() == 1 && _lines[0].empty(); }
 
     const std::vector<std::string>&
-         get_Lines   () const { return _lines; }
-    int  get_Row     () const { return _row; }
-    int  get_Col     () const { return _col; }
-    Mode get_Mode    () const { return _mode; }
+         get_Lines   () const { return _lines;   }
+    int  get_Row     () const { return _row;     }
+    int  get_Col     () const { return _col;     }
+    Mode get_Mode    () const { return _mode;    }
     unsigned
          get_Version () const { return _version; }
     const std::string&
-         get_Cmdline () const { return _cmd; }
+         get_Cmdline () const { return _cmd;     }
     const std::string&
-         get_Status  () const { return _status; }
+         get_Status  () const { return _status;  }
     void start_Insert() { _mode = Mode::Insert; _col = 0; touch(); }
 
+    void set_Cursor(int row, int col)
+    {
+        if (_lines.empty()) { _row = 0; _col = 0; return; }
+        _row = (row < 0) ? 0
+             : (row >= (int)_lines.size() ? (int)_lines.size() - 1 : row);
+        _col = (col < 0) ? 0 : col;
+        clamp_Col();
+        touch();
+    }
+
 private:
-    bool     _dirty  = false;
-    unsigned _version= 0;
-    int      _row    = 0, _col = 0;
-    int      _count  = 0;
+    bool     _dirty = false;
+    unsigned _version = 0;
+    int      _row = 0, _col = 0;
+    int      _count = 0;
 
     std::string _pending;
     std::string _cmd;
     std::string _status;
 
+    // Yank/delete register (vim's unnamed register). Linewise yanks (yy/dd/cc)
+    // set _reg_linewise so paste creates new lines; charwise yanks (yw/dw/cw)
+    // clear it so paste splices inline. Multi-line linewise content is stored
+    // '\n'-joined.
     std::string _register;
     bool        _reg_linewise = false;
 
+    // Undo/redo: full-state snapshots (lines + cursor). Grouped vim-style — one
+    // snapshot per normal-mode edit, and one per insert SESSION (taken when
+    // insert begins, so all typing until Esc undoes as a unit). Any new edit
+    // clears the redo stack.
     struct Snapshot { std::vector<std::string> lines; int row; int col; };
     std::vector<Snapshot> _undo;
     std::vector<Snapshot> _redo;
@@ -81,23 +102,27 @@ private:
     void run_Command     ();
     void cancel_Command  ();
 
-    int  word_EndCol     (int times) const;
-    int  next_WordCol    (int times) const;
-    int  end_WordCol     (int times) const;
-    int  prev_WordCol    (int times) const;
+    // word motion (whitespace-delimited, matching the tokenizer)
+    int  word_EndCol     (int times) const;   // exclusive end of word(s) from _col (for cw)
+    int  next_WordCol    (int times) const;   // start of the n-th next word    (for dw/yw/w)
+    int  end_WordCol     (int times) const;   // last char of the n-th word fwd  (for e)
+    int  prev_WordCol    (int times) const;   // start of the n-th word back     (for b)
 
-    void push_Undo       ();
+    // undo/redo
+    void push_Undo       ();                  // snapshot current state, clear redo
     void undo            ();
     void redo            ();
 
-    void yank_Lines      (int n);
-    void op_ToNextWord   (int times, bool del, bool yank);
-    void op_ChangeWord   (int times);
-    void change_Line     ();
+    // yank / paste / change
+    void yank_Lines      (int n);             // linewise yank n lines from _row
+    void op_ToNextWord   (int times, bool del, bool yank);  // dw / yw
+    void op_ChangeWord   (int times);         // cw
+    void change_Line     ();                  // cc (register filled by caller)
     void paste_After     ();
     void paste_Before    ();
 
-    void indent_Lines    (int n, int dir);
+    // indentation
+    void indent_Lines    (int n, int dir);    // dir +1 = indent, -1 = dedent
 
     bool is_Dirty        ()      const;
     int  get_LineLen     ()      const;