Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / 16e68d5

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

Maybe something with Focus wall or so

Christian Immanuel · 2026-06-15 09:40 · 16e68d56d0506cd3d731ae79f5dfdcab9e14aaff

 assets/sandbox.c             |  78 +++++++++++++++++++-----------
 assets/sandbox.c.last_ok     |  78 +++++++++++++++++++-----------
 src/states/game_state.cpp    | 107 +++++++++++++++++++++++++++++++++++++----
 src/states/game_state.hpp    |  21 +++++++--
 src/systems/input_system.cpp |  13 ++---
 src/systems/input_system.hpp |  34 +++++++------
 src/text/handler.cpp         | 110 +++++++++++++++++++++++++++++++------------
 src/text/handler.hpp         |  44 +++++++++--------
 src/text/mesh.cpp            |   2 +-
 src/text/vim_buffer.hpp      |  30 ++++++------
 10 files changed, 359 insertions(+), 158 deletions(-)

diff --git a/assets/sandbox.c b/assets/sandbox.c
index ac2a96b..1c62ace 100644
--- a/assets/sandbox.c
+++ b/assets/sandbox.c
@@ -1,31 +1,53 @@
-# include "game_mod_abi.h"
-static int n ;
-static DynObject * o ;
-static void box ( double x , double y , double z , double w , double h , double d , int dyn ) {
-o [ n ] .kind = DYN_BOX ; o [ n ] .dynamic = dyn ;
-double hx = w * 0.5 , hy = h * 0.5 , hz = d * 0.5 ;
-o [ n ] .x = x ;
-o [ n ] .y = y + hy ;
-o [ n ] .z = z ;
-o [ n ] .hx = hx ; o [ n ] .hy = hy ; o [ n ] .hz = hz ;
-o [ n ] .r = 0.6 ; o [ n ] .g = 0.6 ; o [ n ] .b = 0.8 ;
-n = n + 1 ;
+#include "game_mod_abi.h"
+
+static int n;
+static DynObject *o;
+
+static void box(double x, double y, double z,
+                double w, double h, double d,
+                int dyn)
+{
+    o[n].kind    = DYN_BOX;
+    o[n].dynamic = dyn;
+
+    double hx = w * 0.5;
+    double hy = h * 0.5;
+    double hz = d * 0.5;
+
+    o[n].x  = x;   o[n].y  = y + hy; o[n].z  = z;
+    o[n].hx = hx;  o[n].hy = hy;     o[n].hz = hz;
+    o[n].r  = 0.6; o[n].g  = 0.6;    o[n].b  = 0.8;
+
+    n = n + 1;
 }
-static void text ( double x , double y , double z , char * t ) {
-o [ n ] .kind = DYN_TEXT ;
-o [ n ] .x = x ; o [ n ] .y = y ; o [ n ] .z = z ;
-o [ n ] .r = 1 ; o [ n ] .g = 1 ; o [ n ] .b = 1 ;
-int i = 0 ;
-while ( t [ i ] != 0 ) { o [ n ] .text [ i ] = t [ i ] ; i = i + 1 ; }
-o [ n ] .text [ i ] = 0 ;
-n = n + 1 ;
+
+static void text(double x, double y, double z, char *t)
+{
+    o[n].kind = DYN_TEXT;
+
+    o[n].x = x; o[n].y = y; o[n].z = z;
+    o[n].r = 1; o[n].g = 1; o[n].b = 1;
+    int i = 0;
+
+    for (i = 0; t[i] != '\0'; i++)
+        o[n].text[i] = t[i];
+
+    o[n].text[i] = 0;
+    n = n + 1;
 }
-int game_build ( DynObject * out , int max ) {
-( void ) max ;
-o = out ; n = 0 ;
-text ( 0 , - 15 , 5 , "hi" ) ;
-box ( 0 , - 16 , 5 , 2 , 1 , 2 , 0 ) ;
-for ( int i = 0 ; i < 100 ; i ++ )
-box ( 0 + i , - 16 , 5 + i % 4 , 1 , 1 + i * 0.5 , 1 , 0 ) ;
-return n ;
+
+int game_build(DynObject *out, int max)
+{
+    (void)max;
+
+    o = out;
+    n = 0;
+
+    text(0, -15, 5, "hi");
+    box(0, -16, 5, 2, 1, 2, 0);
+
+    for (int i = 0; i < 100; i++)
+        box(0 + i, -16, 5 + i % 4, 1, 1 + i * 0.5, 1, 0);
+
+    return n;
 }
diff --git a/assets/sandbox.c.last_ok b/assets/sandbox.c.last_ok
index ac2a96b..1c62ace 100644
--- a/assets/sandbox.c.last_ok
+++ b/assets/sandbox.c.last_ok
@@ -1,31 +1,53 @@
-# include "game_mod_abi.h"
-static int n ;
-static DynObject * o ;
-static void box ( double x , double y , double z , double w , double h , double d , int dyn ) {
-o [ n ] .kind = DYN_BOX ; o [ n ] .dynamic = dyn ;
-double hx = w * 0.5 , hy = h * 0.5 , hz = d * 0.5 ;
-o [ n ] .x = x ;
-o [ n ] .y = y + hy ;
-o [ n ] .z = z ;
-o [ n ] .hx = hx ; o [ n ] .hy = hy ; o [ n ] .hz = hz ;
-o [ n ] .r = 0.6 ; o [ n ] .g = 0.6 ; o [ n ] .b = 0.8 ;
-n = n + 1 ;
+#include "game_mod_abi.h"
+
+static int n;
+static DynObject *o;
+
+static void box(double x, double y, double z,
+                double w, double h, double d,
+                int dyn)
+{
+    o[n].kind    = DYN_BOX;
+    o[n].dynamic = dyn;
+
+    double hx = w * 0.5;
+    double hy = h * 0.5;
+    double hz = d * 0.5;
+
+    o[n].x  = x;   o[n].y  = y + hy; o[n].z  = z;
+    o[n].hx = hx;  o[n].hy = hy;     o[n].hz = hz;
+    o[n].r  = 0.6; o[n].g  = 0.6;    o[n].b  = 0.8;
+
+    n = n + 1;
 }
-static void text ( double x , double y , double z , char * t ) {
-o [ n ] .kind = DYN_TEXT ;
-o [ n ] .x = x ; o [ n ] .y = y ; o [ n ] .z = z ;
-o [ n ] .r = 1 ; o [ n ] .g = 1 ; o [ n ] .b = 1 ;
-int i = 0 ;
-while ( t [ i ] != 0 ) { o [ n ] .text [ i ] = t [ i ] ; i = i + 1 ; }
-o [ n ] .text [ i ] = 0 ;
-n = n + 1 ;
+
+static void text(double x, double y, double z, char *t)
+{
+    o[n].kind = DYN_TEXT;
+
+    o[n].x = x; o[n].y = y; o[n].z = z;
+    o[n].r = 1; o[n].g = 1; o[n].b = 1;
+    int i = 0;
+
+    for (i = 0; t[i] != '\0'; i++)
+        o[n].text[i] = t[i];
+
+    o[n].text[i] = 0;
+    n = n + 1;
 }
-int game_build ( DynObject * out , int max ) {
-( void ) max ;
-o = out ; n = 0 ;
-text ( 0 , - 15 , 5 , "hi" ) ;
-box ( 0 , - 16 , 5 , 2 , 1 , 2 , 0 ) ;
-for ( int i = 0 ; i < 100 ; i ++ )
-box ( 0 + i , - 16 , 5 + i % 4 , 1 , 1 + i * 0.5 , 1 , 0 ) ;
-return n ;
+
+int game_build(DynObject *out, int max)
+{
+    (void)max;
+
+    o = out;
+    n = 0;
+
+    text(0, -15, 5, "hi");
+    box(0, -16, 5, 2, 1, 2, 0);
+
+    for (int i = 0; i < 100; i++)
+        box(0 + i, -16, 5 + i % 4, 1, 1 + i * 0.5, 1, 0);
+
+    return n;
 }
diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 3e7ee84..b0d63d7 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -194,6 +194,33 @@ GameState::pult_DispatchAction(PultAction act)
 void
 GameState::on_Click()
 {
+    if (_focus_active) {
+        const float ws = World::get_BoxScale<float>();
+        const Wall::Transform tf = Wall::get_Transform(_focus_role, ws);
+        const Vec3 n = Wall::inward_Normal(tf.wall_position);
+        const Vec3& o = _ctx.camera->pos;
+        const Vec3& d = _ctx.camera->fwd;
+
+        const double denom = d.x*n.x + d.y*n.y + d.z*n.z;
+        if (denom < -1e-6) { // crosshair meets the wall
+            const double t = ((tf.position.x-o.x)*n.x + (tf.position.y-o.y)*n.y
+                            + (tf.position.z-o.z)*n.z) / denom;
+            if (t > 0.0) {
+                const Vec3 impact { o.x + d.x*t, o.y + d.y*t, o.z + d.z*t };
+
+                if (_focus_role == Wall::Role::Editor) {
+                    const Text::Handler::CursorHit hit =
+                        _editor.resolve_Impact(impact);
+                    wallconnect_BeginEditor();
+                    _editor.place_CursorInsert(hit);
+                }
+                else if (_focus_role == Wall::Role::Program) {
+                    wallconnect_BeginProgram();
+                }
+            }
+        }
+        return;
+    }
     if (_editor.edit_Active()) {
         const Wall::Transform wall_transform =
                     Wall::get_Transform(Wall::Role::Editor, World::get_BoxScale());
@@ -648,6 +675,7 @@ GameState::update_Input(FrameInput& input, double dt)
             case InputCommand::Compile:           on_Compile();           break;
             case InputCommand::Run:               on_Run();               break;
             case InputCommand::DebugPrint:        on_DebugPrint();        break;
+            case InputCommand::FocusToggle:       _focus_on = !_focus_on; break;
             case InputCommand::FreelookStart:     on_FreelookStart();     break;
             case InputCommand::FreelookEnd:       on_FreelookEnd();       break;
             case InputCommand::NumberInc:         on_NumberAdjust(+1);    break;
@@ -677,6 +705,7 @@ GameState::update_Input(FrameInput& input, double dt)
                     _editor.end_Edit();
                     _wall_connection.end();
                     _frame_input.capture_vim = false;
+                    _focus_locked            = false;
                     break;
                 case Text::Handler::EditReq::None:
                     break;
@@ -706,14 +735,14 @@ 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; }
 
-    const bool focus = input.focus_wall;
+    const bool want_focus = _focus_on || _focus_locked;
 
-    if (!_freelook_sys->state.active && !focus)
+    if (!_freelook_sys->state.active && !want_focus)
         _ctx.player->add_Look(input.look_x, input.look_y);
 
     _freelook_sys->update(dt);
 
-    if (focus) {
+    if (want_focus) {
         const bool f = input.forward, b = input.backward,
                    l = input.left,    r = input.right;
         input.forward = input.backward = input.left = input.right = false;
@@ -751,6 +780,7 @@ GameState::update_Objects(double dt, const GlRenderer* gl)
         if (handler) handler->update(dt, *_ctx.camera);
 
     _wall_connection.update(dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());
+    focusLabel_Update      (dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());
 
     DynArray<GameObject*> particle_colliders;
     particle_colliders.reserve(_ctx.scenery.size());
@@ -875,6 +905,20 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
     { FP_ZONE("render.particles"  ); _particle_sys    ->draw(renderer, *_ctx.camera, *_ctx.light); }
     { FP_ZONE("render.celebration"); _celebration_sys ->draw(renderer, *_ctx.camera, *_ctx.light); }
     { FP_ZONE("render.wall_edit"  ); _wall_connection.render(renderer, *_ctx.camera, *_ctx.light); }
+    { FP_ZONE("render.focus_label");
+        if (_focus_active && _focus_label) {
+            const double hw = static_cast<double>(_focus_label->half_extent);
+            _focus_label->pos = Vec3{
+                _focus_label_pos.x + _ctx.camera->right.x * hw,
+                _focus_label_pos.y + _ctx.camera->right.y * hw,
+                _focus_label_pos.z + _ctx.camera->right.z * hw,
+            };
+            _focus_label->yaw   = _focus_label_yaw;
+            _focus_label->pitch = 0.0f;
+            renderer.clear_Depth();
+            _focus_label->draw(renderer, *_ctx.camera, *_ctx.light);
+        }
+    }
     { FP_ZONE("render.end_Frame"  ); renderer.end_Frame(&window); }
 }
 
@@ -1124,11 +1168,13 @@ GameState::sandbox_LoadFileToWall()
     }
 
     DynArray<std::string> lines;
-    for (const auto& toks : SourceTokens::tokenize_Lines(src)) {
-        std::string joined;
-        for (const std::string& tk : toks) { if (!joined.empty()) joined += ' '; joined += tk; }
-        lines.push_back(joined);
+    std::string cur;
+    for (char c : src) {
+        if      (c == '\n') { lines.push_back(cur); cur.clear(); }
+        else if (c != '\r') { cur += c; }
     }
+    if (!cur.empty())  lines.push_back(cur);
+    if (lines.empty()) lines.push_back("");
 
     _editor.set_Lines(lines);
 }
@@ -1251,6 +1297,7 @@ GameState::on_WallConnectExit()
 
     _frame_input.capture_typing = false;
     _frame_input.typing_buffer.clear();
+    _focus_locked = false;
 }
 
 
@@ -1342,6 +1389,7 @@ GameState::compute_FocusTarget()
     _focus_normal = n;
     _focus_dist   = SDL_max(SDL_max(d_v, d_h), 2.0);
     _focus_yaw_r  = tf.yaw;
+    _focus_role   = best_role;
     return true;
 }
 
@@ -1349,10 +1397,14 @@ GameState::compute_FocusTarget()
 void
 GameState::update_WallFocus(FrameInput& input, double dt)
 {
+    const bool want = _focus_on || _focus_locked;
     constexpr double DUR = 0.25;
 
-    if (input.focus_wall) {
-        if (!_focus_active) { if (!compute_FocusTarget()) return; _focus_active = true; }
+    if (want) {
+        if (!_focus_active) {
+            if (!compute_FocusTarget()) return;
+            _focus_active = true;
+        }
         _focus_t = SDL_min(1.0, _focus_t + dt / DUR);
 
         const double ws = World::get_BoxScale();
@@ -1410,3 +1462,40 @@ GameState::update_WallFocus(FrameInput& input, double dt)
     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) };
 }
+
+
+void
+GameState::focusLabel_Update(double dt, const Camera& cam, float aspect, float fov_y)
+{
+    if (!_focus_active) { _focus_label_init = false; return; }
+
+    if (!_focus_label)
+        _focus_label = Text::make_Token("focus", Text::Token::depth_wall, 0.0016f,
+                                        Vec3{0, 0, 0}, SDL_FColor{0.35f, 0.55f, 1.0f, 1.0f});
+
+    const Vec3 up = Math::cross(cam.right, cam.fwd);
+
+    const double dist   = 2.2;
+    const double half_h = dist * static_cast<double>(SDL_tanf(fov_y * 0.5f));
+    const double half_w = half_h * static_cast<double>(aspect);
+
+    const double fx   = 0.82;
+    const double fy   = 0.80;
+    const double left = half_w * fx;
+    const double upamt= half_h * fy;
+
+    const Vec3 target {
+        cam.pos.x + cam.fwd.x*dist - cam.right.x*left + up.x*upamt,
+        cam.pos.y + cam.fwd.y*dist - cam.right.y*left + up.y*upamt,
+        cam.pos.z + cam.fwd.z*dist - cam.right.z*left + up.z*upamt,
+    };
+
+    if (!_focus_label_init) { _focus_label_pos = target; _focus_label_init = true; }
+
+    const double k = 1.0 - SDL_exp(-6.0 * dt);     // exponential trail = "delay in movement"
+    _focus_label_pos.x += (target.x - _focus_label_pos.x) * k;
+    _focus_label_pos.y += (target.y - _focus_label_pos.y) * k;
+    _focus_label_pos.z += (target.z - _focus_label_pos.z) * k;
+
+    _focus_label_yaw = static_cast<float>(SDL_atan2(-cam.fwd.x, -cam.fwd.z));
+}
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 25baeaa..6cb335a 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -83,14 +83,23 @@ class GameState : public IState
     static constexpr size_t LABEL_HIT_BASE = size_t(1) << 48;
 
     // focus wall jump START
-    Vec3   _focus_center  {};        // look-at point on the wall plane (panned by scroll)
-    Vec3   _focus_normal  {};        // inward normal of the focused wall
+    Vec3   _focus_center    {};        // look-at point on the wall plane (panned by scroll)
+    Vec3   _focus_normal    {};        // inward normal of the focused wall
+    Vec3   _focus_label_pos {};
     double _focus_t       { 0.0 };
     double _focus_dist    { 4.0 };   // camera distance from the wall plane (dolly via W/S)
     double _focus_yaw_r   { 0.0 };
     float  _fov_y         { 0.0f };
     float  _aspect        { 0.0f };
+    float  _focus_label_yaw  { 0.0f };
+    Text::TokenPtr
+           _focus_label;
     bool   _focus_active  { false };
+    bool   _focus_locked  { false };
+    bool   _focus_on      { false };
+    bool           _focus_label_init { false };
+    Wall::Role
+           _focus_role    { Wall::Role::Editor };
     // focus wall jump END
 
     void wallconnect_BeginEditor ();
@@ -152,9 +161,11 @@ class GameState : public IState
     // 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);
+    bool compute_FocusTarget();
+    void update_WallFocus(FrameInput& input, double dt);
+    void focusLabel_Update(double dt, const Camera& cam, float aspect, float fov_y);
+    Text::Handler*
+         handler_For(Wall::Role r);
     // focus wall jump END
 
 public:
diff --git a/src/systems/input_system.cpp b/src/systems/input_system.cpp
index 640319a..9758cd3 100644
--- a/src/systems/input_system.cpp
+++ b/src/systems/input_system.cpp
@@ -11,13 +11,13 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
 {
     switch (e.type) {
         case SDL_EVENT_MOUSE_MOTION:
-            input.look_x += static_cast<int>(e.motion.xrel);
-            input.look_y += static_cast<int>(e.motion.yrel);
+            input.look_x += (int)e.motion.xrel;
+            input.look_y += (int)e.motion.yrel;
             break;
 
         case SDL_EVENT_MOUSE_WHEEL:
-            input.scroll_x += static_cast<double>(e.wheel.x);
-            input.scroll_y += static_cast<double>(e.wheel.y);
+            input.scroll_x += (double)e.wheel.x;
+            input.scroll_y += (double)e.wheel.y;
             break;
 
         case SDL_EVENT_MOUSE_BUTTON_DOWN: {
@@ -85,7 +85,9 @@ 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_X: if (!e.key.repeat)
+                                 input.commands.push_back(InputCommand::FocusToggle);
+                             break;
 
                 case SDLK_F1:     game.change_State(cpputils::make_unique<GameState>()); break;
                 case SDLK_F2:     input.commands.push_back(InputCommand::SandboxRestore);break;
@@ -118,7 +120,6 @@ 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 5116d03..57ee956 100644
--- a/src/systems/input_system.hpp
+++ b/src/systems/input_system.hpp
@@ -13,6 +13,7 @@ enum class InputCommand : uint8_t
 {
     Jump,
     Click,
+    FocusToggle,
     Drop,
     ShootAll,
     Compile,
@@ -29,24 +30,12 @@ enum class InputCommand : uint8_t
 
 struct FrameInput
 {
-    DynArray<int>          vim_keys;
-    DynArray<InputCommand> commands;
-
-    std::string            typing_buffer;
-
-    double scroll_x = 0.0;
-    double scroll_y = 0.0;
-
-    int    look_x   = 0;
-    int    look_y   = 0;
-
-    bool forward    = false;
-    bool backward   = false;
-    bool left       = false;
-    bool right      = false;
-    bool pick       = false;
-    bool shoot      = false;
-    bool focus_wall = false;
+    bool forward  = false;
+    bool backward = false;
+    bool left     = false;
+    bool right    = false;
+    bool pick     = false;
+    bool shoot    = false;// X held: focus the faced wall
 
     bool normal_keys    = false;
 
@@ -56,6 +45,15 @@ struct FrameInput
     bool typing_mode   = false;
     bool typing_commit = false;
     bool typing_cancel = false;
+
+    int    look_x   = 0;
+    int    look_y   = 0;
+    double scroll_x = 0.0;
+    double scroll_y = 0.0;
+
+    std::string            typing_buffer;
+    DynArray<int>          vim_keys;
+    DynArray<InputCommand> commands;
 };
 
 
diff --git a/src/text/handler.cpp b/src/text/handler.cpp
index 6fc17de..a85ae9a 100644
--- a/src/text/handler.cpp
+++ b/src/text/handler.cpp
@@ -237,14 +237,14 @@ Handler::detach(Token::Data* t)
 {
     if (edit_Active()) return nullptr;
 
-    for (size_t li = 0; li < _lines.size(); ++li) {
-        Line& l = _lines[li];
+    for (size_t line_id = 0; line_id < _lines.size(); ++line_id) {
+        Line& l = _lines[line_id];
         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>(li));
+                    _lines.erase(_lines.begin() + static_cast<std::ptrdiff_t>(line_id));
                 mark_Dirty();
                 if (_vim) { sync_VimFromLines(); relayout_Mono(); }   // keep buffer in step
                 return out;
@@ -306,17 +306,17 @@ Handler::resolve_Target(double y) const
     std::sort(order.begin(), order.end(),
         [&](size_t a, size_t b){ return _lines[a].y > _lines[b].y; });
 
-    auto glyph_top = [&](size_t li) {
-        return _lines[li].y
-             + (double)_lines[li].tokens.front()->half_extent_y
+    auto glyph_top = [&](size_t line_id) {
+        return _lines[line_id].y
+             + (double)_lines[line_id].tokens.front()->half_extent_y
              + ConfigWall::merge_top_margin;
     };
-    auto underline = [&](size_t li) { return _lines[li].y - ConfigWall::underline_drop; };
+    auto underline = [&](size_t line_id) { return _lines[line_id].y - ConfigWall::underline_drop; };
 
     // 1) Merge: within a line's glyph band.
-    for (size_t li : order)
-        if (y <= glyph_top(li) && y >= underline(li))
-            return { Target::Merge, li, 0.0, 0.0 };
+    for (size_t line_id : order)
+        if (y <= glyph_top(line_id) && y >= underline(line_id))
+            return { Target::Merge, line_id, 0.0, 0.0 };
 
     const double LH = ConfigWall::line_height;
 
@@ -774,12 +774,23 @@ Handler::relayout_Mono()
     const double ws     = World::get_BoxScale();
     const double bottom_y = -ws + 1.2;
 
-    const double anchor_cursor = _eye_y + static_cast<double>(R) * lh;
     const double anchor_bottom = bottom_y + static_cast<double>(N - 1) * lh;
-    // 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;
+
+    if (_capturing) {
+        // vim-style scrolloff. MARGIN lines are kept above & below the cursor, so the
+        // cursor stays centred and the text scrolls on every step. PAD leaves a few
+        // blank rows of overscroll at the file ends so the first/last line sits just
+        // inside the view instead of flush against (and clipped by) the screen edge.
+        constexpr int MARGIN = 4;   // lines above & below the cursor (lower if rows clip)
+        constexpr int PAD    = 1;   // blank rows kept above the first / below the last line
+        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 = mono_CharWidth();
     _he_y   = mono_HalfExtentY();
@@ -847,18 +858,18 @@ Handler::relayout_Mono()
           block_code_w = SDL_max(block_code_w, _char_w * (double)vcol);   // ← visual width, not s.size()
         }
 
-        size_t ti = 0;
+        size_t token_id = 0;
         for (const Chunk& ch : chunks) {
             TokenPtr t;
-            if (reuse_line && ti < old[i].tokens.size())
-                t = std::move(old[i].tokens[ti]);
+            if (reuse_line && token_id < old[i].tokens.size())
+                t = std::move(old[i].tokens[token_id]);
             else {
                 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), ch.vcol, ch.vspan });
-            ++ti;
+            ++token_id;
         }
         built.push_back(std::move(br));
     }
@@ -1112,7 +1123,6 @@ Handler::begin_Edit(double eye_y)
     _capturing = true;
     _eye_y     = eye_y;
     _status.clear();
-
     _layout = LayoutMode::Mono;
     relayout_Mono();
     mark_Dirty();
@@ -1153,6 +1163,8 @@ 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();
@@ -1180,10 +1192,12 @@ Handler::feed_Keys(DynArray<int>& keys, double eye_y)
         default: break;
     }
 
-    if (_vim->get_Version() != _vim_seen_version) {
+    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_Mono();
-        mark_Dirty();
+        relayout_Mono();                 // re-anchor for scrolloff (motion) or rebuild (edit)
+        if (edited) mark_Dirty();         // re-highlight only when text actually changed
     }
     return req;
 }
@@ -1226,17 +1240,17 @@ 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;
+    for (size_t line_id = 0; line_id < _lines.size(); ++line_id) {
+        const auto& toks = _lines[line_id].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);
+        for (size_t token_id = 0; token_id < toks.size(); ++token_id) {
+            if (toks[token_id].get() == t) {
+                _vim->set_Cursor((int)line_id, (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)
+            if (toks[token_id]) col += toks[token_id]->text.size();    // raw byte length (space/tab = 1)
         }
     }
     return false;
@@ -1274,6 +1288,44 @@ Handler::cursor_AtImpact(const Vec3& impact)
 }
 
 
+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_Mono();                   // re-anchor: scrolls this cursor into the player's view
+}
+
+
 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 34738ce..da8bab4 100644
--- a/src/text/handler.hpp
+++ b/src/text/handler.hpp
@@ -65,9 +65,11 @@ public:
     TokenPtr     detach       (Token::Data* t);
     void         attach       (DynArray<TokenPtr> tokens, const Vec3& impact);
 
-    
-    
-    
+    // Spatial classifier for thrown words on the always-mono editor grid.
+    // 80/20 rule: a non-whitespace token's central 80% (of its column span) is a
+    // hit → DropToken; the outer 20% inserts beside it; whitespace cells always
+    // insert; vertical gaps between rows create a new line (row may be negative
+    // to prepend above the top, or >= line count to append below).
     enum class HitKind : uint8_t { Insert, NewLine, DropToken };
     struct EditorHit { HitKind kind; int row; int col; size_t line_id; size_t token_id; };
     EditorHit classify_Impact(const Vec3& impact) const;
@@ -89,11 +91,11 @@ 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);
@@ -102,19 +104,22 @@ public:
     EditReq feed_Keys  (DynArray<int>& keys, double eye_y);
     void    mark_Saved ();
 
-    int  cursor_Row      () const;
-    int  cursor_Col      () const;
-    int  cursor_VisualCol() const;
-    bool cursor_ToToken  (Token::Data* t);   
-    bool cursor_AtImpact (const Vec3& impact); 
+    int cursor_Row      () const;
+    int cursor_Col      () const;
+    int cursor_VisualCol() const;
+    bool cursor_ToToken (Token::Data* t);   // click-to-place: move cursor to a clicked wall token
+    bool cursor_AtImpact(const Vec3& impact); // click-to-place: exact char under the crosshair
 
+    struct CursorHit { int row = 0; int byte = 0; bool ok = false; };
+    CursorHit resolve_Impact   (const Vec3& impact) const;  // map crosshair → char, current layout
+    void      place_CursorInsert(const CursorHit& hit);     // apply after begin_Edit + scroll in
     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);
@@ -152,7 +157,7 @@ private:
     DynArray<std::string>     _status;
     const DynArray<TokenPtr>* _borrowed { nullptr };
 
-    size_t _view_tag { 0 };          
+    size_t _view_tag { 0 };          // identity of currently-materialised info-wall content; 0 = none
 
     bool _lex_enabled  { true };
     bool _show_numbers { false };
@@ -160,21 +165,22 @@ 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 };
     double _row0_y     { 0.0 };
     double _eye_y      { 0.0 };
+    int    _scroll_top { 0 };        // index of the row stuck to the top of the window
 
     cpputils::unique_ptr<VimBuffer> _vim;
     unsigned                        _vim_seen_version { 0 };
-    bool                            _capturing { false };   
+    bool                            _capturing { false };   // keys are being captured (connected & editing)
 
     enum class PositionMode : uint8_t { Absolute, Anchor };
     const Vec3*  _anchor_ptr    { nullptr };
@@ -184,7 +190,7 @@ private:
     Vec3         _attach_offset {};
     Vec3         _centre        {};
     SDL_FColor   _style_color   { 1.0f, 1.0f, 0.0f, 1.0f };
-    std::string  _label_text;   
+    std::string  _label_text;
     PositionMode _pos_mode      { PositionMode::Absolute };
     DynArray<double> _label_off;
 
@@ -192,13 +198,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();   
+    void   sync_VimFromLines();   // editor: push _lines content back into the persistent buffer
 
-    
+    // Snapped placement internals
     struct Target {
         enum   Kind { Merge, Insert } kind;
         size_t line_index;
diff --git a/src/text/mesh.cpp b/src/text/mesh.cpp
index 5dfb2b6..773f4d3 100644
--- a/src/text/mesh.cpp
+++ b/src/text/mesh.cpp
@@ -361,7 +361,7 @@ bool
 TextMesh::build(const std::string& font_path,
                 const std::string& text, int size, float /*depth*/, float scale)
 {
-    SPIKE("mesh.build");
+    //SPIKE("mesh.build");
     verts.clear(); tris.clear(); is_front.clear(); vertex_normals.clear();
 
     FT_Face face = acquire_Face(font_path, size);
diff --git a/src/text/vim_buffer.hpp b/src/text/vim_buffer.hpp
index be93dba..37fda83 100644
--- a/src/text/vim_buffer.hpp
+++ b/src/text/vim_buffer.hpp
@@ -37,18 +37,20 @@ public:
     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 enter_Insert() { _mode = Mode::Insert; clamp_Col(); touch(); }   // insert at current cursor
+
     void set_Cursor(int row, int col)
     {
         if (_lines.empty()) { _row = 0; _col = 0; return; }
@@ -61,14 +63,13 @@ public:
 
 private:
     struct Snapshot { std::vector<std::string> lines; int row; int col; };
-
-    std::vector<std::string> _saved;
-    std::vector<std::string> _lines{ "" };
     std::vector<Snapshot>    _undo;
     std::vector<Snapshot>    _redo;
+    std::vector<std::string> _saved;
+    std::vector<std::string> _lines{ "" };
 
-    Mode   _mode = Mode::Normal;
-    Signal _sig  = Signal::None;
+    Mode     _mode = Mode::Normal;
+    Signal   _sig = Signal::None;
 
     std::string _pending;
     std::string _cmd;
@@ -76,11 +77,10 @@ private:
     std::string _register;
 
     unsigned _version = 0;
-    int      _row     = 0,
-             _col     = 0;
-    int      _count   = 0;
+    int      _row = 0, _col = 0;
+    int      _count = 0;
 
-    bool     _dirty   = false;
+    bool     _dirty = false;
     bool     _reg_linewise = false;
 
     void touch           ();