Nimbin[12]?SDL & Graphics / sdl_runtime_compiler / commits / a433e1b

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 vim y p w .. With Claude

Christian Immanuel · 2026-06-10 17:47 · a433e1be75a6b1377b35931211df623e8d1d2c25

 src/states/game_state.cpp       | 180 +++++++++++-----------
 src/states/game_state.hpp       |  11 +-
 src/systems/compiler_system.cpp |   4 +-
 src/systems/input_system.cpp    |  20 ++-
 src/systems/input_system.hpp    |   5 +-
 src/text/handler.cpp            |   7 +-
 src/text/vim_buffer.cpp         | 331 ++++++++++++++++++++++++++++++++++++++--
 src/text/vim_buffer.hpp         |  40 ++++-
 8 files changed, 466 insertions(+), 132 deletions(-)

diff --git a/src/states/game_state.cpp b/src/states/game_state.cpp
index 1a3c77a..c4d579c 100644
--- a/src/states/game_state.cpp
+++ b/src/states/game_state.cpp
@@ -23,8 +23,8 @@
 #include "wall/label.hpp"
 #include "wall/wall.hpp"
 
-GameState::GameState(size_t starting_level)
 
+GameState::GameState(size_t starting_level)
 {
     _ctx.player = cpputils::make_unique<Player>();
     _ctx.world  = cpputils::make_unique<World> ();
@@ -97,8 +97,8 @@ GameState::GameState(size_t starting_level)
 
     if (starting_level >= Level::get_Count()) starting_level = 0;
     _level_director  = cpputils::make_unique<Level::Director>(Level::get_Data(starting_level));
-
     _level_director->enter_Step(_ctx);
+
     _word_ai_sys->set_MaxActiveAi(_level_director->get_MaxAiForStep());
 
     _hint_sys = cpputils::make_unique<HintSystem>();
@@ -190,6 +190,7 @@ GameState::pult_DispatchAction(PultAction act)
     }
 }
 
+
 void
 GameState::on_Click()
 {
@@ -244,8 +245,6 @@ GameState::on_DropLast()
     if (!word) return;
     revert_ToSingle(word.get());
 
-    //infowall_Clear();
-
     word->body.mode = PhysicsMode::Dynamic;
     word->body.vel  = { _ctx.camera->fwd.x * 1.5, 0.8, _ctx.camera->fwd.z * 1.5 };
 
@@ -271,8 +270,6 @@ GameState::on_ShootOne()
     if (!word) return false;
     revert_ToSingle(word.get());
 
-    //infowall_Clear();
-
     _freelook_sys->state.active  = false;
     _freelook_sys->state.lerping = false;
 
@@ -299,8 +296,6 @@ GameState::on_ShootAll()
 {
     if (_carried.empty()) return;
 
-    //infowall_Clear();
-
     _freelook_sys->state.active  = false;
     _freelook_sys->state.lerping = false;
 
@@ -571,27 +566,47 @@ GameState::handle_Event(Game& game, const SDL_Event& event)
     _input_sys->handle_Event(game, event, _frame_input);
 }
 
+
 void
-GameState::handle_Input(FrameInput& input, double dt) {
+GameState::update(double dt, const GlRenderer* gl)
+{
+    FrameInput& input = _frame_input;
+
+    { FP_ZONE("update.input"      ); update_Input       (input, dt);}
+    { FP_ZONE("update.world_sim"  ); update_Physics     (dt);       }
+    { FP_ZONE("update.present"    ); update_Objects     (dt, gl);   }
+    { FP_ZONE("update.subsystems" ); update_Compiler    ();         }
+    { FP_ZONE("update.progression"); update_Progression (dt);       }
+    { FP_ZONE("update.highlight"  ); update_Highlighting();         }
+
+    input.look_x = 0;
+    input.look_y = 0;
+    input.commands.clear();
+}
+
+
+void
+GameState::update_Input(FrameInput& input, double dt)
+{
     for (InputCommand cmd : input.commands) {
         switch (cmd) {
-            case InputCommand::Jump:              { FP_ZONE("on_Jump");             on_Jump();}              break;
-            case InputCommand::Click:             { FP_ZONE("on_Click");            on_Click();}             break;
-            case InputCommand::Drop:              { FP_ZONE("on_DropLast");         on_DropLast();}          break;
-            case InputCommand::ShootAll:          { FP_ZONE("on_ShootAll");         on_ShootAll();}          break;
-            case InputCommand::Compile:           { FP_ZONE("on_Compile");          on_Compile();}           break;
-            case InputCommand::Run:               { FP_ZONE("on_Run");              on_Run();}               break;
-            case InputCommand::DebugPrint:        { FP_ZONE("on_DebugPrint");       on_DebugPrint();}        break;
-            case InputCommand::FreelookStart:     { FP_ZONE("on_FreelookStart");    on_FreelookStart();}     break;
-            case InputCommand::FreelookEnd:       { FP_ZONE("on_FreelookEnd");      on_FreelookEnd();}       break;
-            case InputCommand::NumberInc:         { FP_ZONE("on_NumberAdjust");     on_NumberAdjust(+1);}    break;
-            case InputCommand::NumberDec:         { FP_ZONE("on_NumberAdjust");     on_NumberAdjust(-1);}    break;
-            case InputCommand::TypingStart:       { FP_ZONE("on_TypingStart ");     on_TypingStart (input);} break;
-            case InputCommand::TypingCommit:      { FP_ZONE("on_TypingCommit");     on_TypingCommit(input);} break;
-            case InputCommand::TypingCancel:      { FP_ZONE("on_TypingCancel");     on_TypingCancel(input);} break;
-            case InputCommand::SandboxRestore:    { FP_ZONE("on_TypingCancel");     on_SandboxRestore();}      break;
-            case InputCommand::ConnectPultSubmit: { FP_ZONE("on_WallConnectSubmit");on_WallConnectSubmit();} break;
-            case InputCommand::ConnectPultExit:   { FP_ZONE("on_WallConnectExit");  on_WallConnectExit();}   break;
+            case InputCommand::Jump:              on_Jump();              break;
+            case InputCommand::Click:             on_Click();             break;
+            case InputCommand::Drop:              on_DropLast();          break;
+            case InputCommand::ShootAll:          on_ShootAll();          break;
+            case InputCommand::Compile:           on_Compile();           break;
+            case InputCommand::Run:               on_Run();               break;
+            case InputCommand::DebugPrint:        on_DebugPrint();        break;
+            case InputCommand::FreelookStart:     on_FreelookStart();     break;
+            case InputCommand::FreelookEnd:       on_FreelookEnd();       break;
+            case InputCommand::NumberInc:         on_NumberAdjust(+1);    break;
+            case InputCommand::NumberDec:         on_NumberAdjust(-1);    break;
+            case InputCommand::TypingStart:       on_TypingStart (input); break;
+            case InputCommand::TypingCommit:      on_TypingCommit(input); break;
+            case InputCommand::TypingCancel:      on_TypingCancel(input); break;
+            case InputCommand::SandboxRestore:    on_SandboxRestore();    break;
+            case InputCommand::ConnectPultSubmit: on_WallConnectSubmit(); break;
+            case InputCommand::ConnectPultExit:   on_WallConnectExit();   break;
         }
     }
 
@@ -639,12 +654,7 @@ GameState::handle_Input(FrameInput& input, double dt) {
     if (_shoot_cooldown > 0.0) _shoot_cooldown -= dt;
     if (input.shoot && _shoot_cooldown <= 0.0)
         { FP_ZONE("handle_input.onShootO"); if (on_ShootOne()) _shoot_cooldown = 0.15; }
-}
-
 
-void
-GameState::update_PlayerCamera(FrameInput& input, double dt)
-{
     if (!_freelook_sys->state.active)
         _ctx.player->add_Look(input.look_x, input.look_y);
 
@@ -653,61 +663,49 @@ GameState::update_PlayerCamera(FrameInput& input, double dt)
     _ctx.camera->sync_FromPlayer(*_ctx.player);
 }
 
+
 void
-GameState::update(double dt, const GlRenderer* gl)
+GameState::update_Objects(double dt, const GlRenderer* gl)
 {
-    FrameInput& input = _frame_input;
+    for (Wall::Label& lbl : _wall_labels)
+        lbl.update(dt, *_ctx.camera);
 
-    { FP_ZONE("update.input_dispatch");
-        handle_Input(input, dt);
-    }
+    carriedline_Update();
 
-    { FP_ZONE("update.player_cam");
-        update_PlayerCamera(input, dt);
-    }
+    _word_anim_sys->update(_anims_add, _anims_drop, _carried, _ctx.words, dt);
 
-    { FP_ZONE("update.world_simulation");
-        update_WorldSimulation(dt);
+    if (_carried.size != _carried_last_size) {
+        if (_carried.size > 0)
+            lex_CarriedLine(_carried);
+        _carried_last_size = _carried.size;
     }
 
-    { FP_ZONE("update.post_step");
+    _pult_sys ->update(dt, *_ctx.camera);
+    _plate_sys->update(dt, *_ctx.player, _level_director->get_StepPlates());
 
-        for (Wall::Label& lbl : _wall_labels)
-            lbl.update(dt, *_ctx.camera);
+    for (Text::Handler* h : _text_walls)
+        if (h) h->update(dt, *_ctx.camera);
 
-        carriedline_Update();
+    _wall_connection.update(dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());
 
-        _word_anim_sys->update(_anims_add, _anims_drop, _carried, _ctx.words, dt);
-        if (_carried.size != _carried_last_size) {
-            if (_carried.size > 0)
-                lex_CarriedLine(_carried);
-            _carried_last_size = _carried.size;
-        }
-
-        _pult_sys->  update(dt, *_ctx.camera);
-        _plate_sys-> update(dt, *_ctx.player, _level_director->get_StepPlates());
-
-        for (Text::Handler* h : _text_walls)
-            if (h) h->update(dt, *_ctx.camera);
-
-        _wall_connection.update(dt, *_ctx.camera, gl->get_Aspect(), gl->get_Fov_y());
-    }
+    DynArray<GameObject*> particle_colliders;
+    particle_colliders.reserve(_ctx.scenery.size());
+    for (const GameObjectPtr& s : _ctx.scenery)
+        if (s) particle_colliders.push_back(s.get());
+    _particle_sys->update(dt, particle_colliders, World::get_BoxScale());
+}
 
 
-    { FP_ZONE("update.particles");
-        DynArray<GameObject*> particle_colliders;
-        particle_colliders.reserve(_ctx.scenery.size());
-        for (const GameObjectPtr& s : _ctx.scenery)
-            if (s) particle_colliders.push_back(s.get());
-        _particle_sys->update(dt, particle_colliders, World::get_BoxScale());
-    }
-
+void
+GameState::update_Compiler()
+{
     _compiler_sys->update(_compiler);
     if (_compiler_sys->last_compile_ok) {
         _compiler_sys->last_compile_ok = false;
         _level_director->record_CompileOk();
     }
 
+    // TODO: the dyn path may still blocks the frame in dyn_Apply
     _dyn_loader_sys->update(_compiler);
     if (_dyn_loader_sys->last_build_ok) {
         _dyn_loader_sys->last_build_ok = false;
@@ -715,28 +713,28 @@ GameState::update(double dt, const GlRenderer* gl)
         SourceTokens::copy_File(sandbox_CanonicalPath(), sandbox_BackupPath());
     }
 
-    { FP_ZONE("update._program_sys");
-        _program_sys->update(_program);
-    }
+    _program_sys->update(_program);
+}
 
-    { FP_ZONE("update.goals");
-        _celebration_sys->tick(dt, *_particle_sys);
-        level_TryAdvance(dt);
-    }
 
-    { FP_ZONE("update.highlight");
-        if (_editor.is_Dirty())
-            _highlight_sys->request(_editor.render_Source(), _editor.generation());
+void
+GameState::update_Highlighting()
+{
+    if (_editor.is_Dirty())
+        _highlight_sys->request(_editor.render_Source(), _editor.generation());
+
+    DynArray<Text::HiSpan> spans;
+    uint64_t gen;
+    if (_highlight_sys->poll(spans, gen))
+        _editor.apply_Highlight(spans, gen);
+}
 
-        DynArray<Text::HiSpan> spans;
-        uint64_t gen;
-        if (_highlight_sys->poll(spans, gen))
-            _editor.apply_Highlight(spans, gen);
-    }
 
-    input.look_x = 0;
-    input.look_y = 0;
-    input.commands.clear();
+void
+GameState::update_Progression(double dt)
+{
+    _celebration_sys->tick(dt, *_particle_sys);
+    level_TryAdvance(dt);
 }
 
 
@@ -818,14 +816,7 @@ GameState::render(SDL_Window& window, GlRenderer& renderer)
 
 
 void
-GameState::infowall_Clear()
-{
-    _info.clear();
-}
-
-
-void
-GameState::update_WorldSimulation(double dt)
+GameState::update_Physics(double dt)
 {
     _ctx.player->prev_pos = _ctx.player->pos;
 
@@ -1201,6 +1192,7 @@ GameState::on_EditorClear()
     _editor.clear_ToFloor(_ctx.words);
 }
 
+
 void
 GameState::on_EditorScroll(int8_t dt)
 {
diff --git a/src/states/game_state.hpp b/src/states/game_state.hpp
index 318d00d..4f98762 100644
--- a/src/states/game_state.hpp
+++ b/src/states/game_state.hpp
@@ -115,9 +115,6 @@ class GameState : public IState
     void typing_ClearPreview   ();
     void typing_RebuildPreview (const std::string& s);
 
-    void update_WorldSimulation(double dt);
-
-    void infowall_Clear           ();
 
     void projectile_FeedProgramInput(ProjectileResult&);
     void projectile_LandOnFloor     (ProjectileResult&);
@@ -128,8 +125,12 @@ class GameState : public IState
 
     void debug_SpawnWords   (size_t n);
 
-    void handle_Input       (FrameInput&, double dt);
-    void update_PlayerCamera(FrameInput& input, double dt);
+    void update_Input       (FrameInput& input, double dt);
+    void update_Physics     (double dt);
+    void update_Objects     (double dt, const GlRenderer* gl);
+    void update_Compiler    ();
+    void update_Highlighting();
+    void update_Progression (double dt);
 
     // dynamic-lib sandbox START
     void dyn_Clear                   ();
diff --git a/src/systems/compiler_system.cpp b/src/systems/compiler_system.cpp
index 29399f7..2e5f628 100644
--- a/src/systems/compiler_system.cpp
+++ b/src/systems/compiler_system.cpp
@@ -38,7 +38,7 @@ CompilerSystem::compile(Text::Handler& out, const std::string& src)
 void
 CompilerSystem::run_Compile(CompilerSystem* self, std::string src)
 {
-    FILE* f = fopen("/tmp/game_prog.c", "w");
+    FILE* f = fopen("/tmp/game_prog.cpp", "w");
     if (!f) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION,
                      "CompilerSystem: could not write temp file");
@@ -48,7 +48,7 @@ CompilerSystem::run_Compile(CompilerSystem* self, std::string src)
     fputs(src.c_str(), f);
     fclose(f);
 
-    FILE* pipe = popen("LC_ALL=C gcc /tmp/game_prog.c -o /tmp/game_prog 2>&1", "r");
+    FILE* pipe = popen("LC_ALL=C g++ /tmp/game_prog.cpp -o /tmp/game_prog 2>&1", "r");
     if (!pipe) {
         SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "CompilerSystem: popen failed");
         self->_running.store(false);
diff --git a/src/systems/input_system.cpp b/src/systems/input_system.cpp
index 03a50c6..ceebf0b 100644
--- a/src/systems/input_system.cpp
+++ b/src/systems/input_system.cpp
@@ -30,19 +30,15 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
         case SDL_EVENT_KEY_DOWN: {
             SDL_Keycode sym = e.key.key;
 
-            if (input.capture_vim) {
+            if (input.capture_vim && !input.normal_keys) {
                 const bool ctrl = (e.key.mod & SDL_KMOD_CTRL);
-                if (ctrl) {
-                    switch (sym) {
-                        case SDLK_W: input.forward  = true; break;
-                        case SDLK_A: input.left     = true; break;
-                        case SDLK_S: input.backward = true; break;
-                        case SDLK_D: input.right    = true; break;
-                    }
-                }
-                else if (sym == SDLK_RETURN)    input.vim_keys.push_back(-1);
+
+                if (ctrl) { input.normal_keys = true; break; }
+
+                     if (sym == SDLK_RETURN)    input.vim_keys.push_back(-1);
                 else if (sym == SDLK_ESCAPE)    input.vim_keys.push_back(-2);
                 else if (sym == SDLK_BACKSPACE) input.vim_keys.push_back(-3);
+                else if (sym == SDLK_TAB)       input.vim_keys.push_back(-4);
                 break;
             }
             if (input.capture_typing) {
@@ -117,6 +113,8 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
                 case SDLK_D: input.right    = false; break;
                 case SDLK_E: input.pick     = false; break;
 
+                case SDLK_LCTRL: input.normal_keys = false; break;
+
                 case SDLK_LSHIFT:
                 case SDLK_RSHIFT:
                      if (!input.capture_typing && !input.capture_vim)
@@ -127,7 +125,7 @@ InputSystem::handle_Event(Game& game, const SDL_Event& e, FrameInput& input)
         }
 
         case SDL_EVENT_TEXT_INPUT:
-            if (input.capture_vim) {
+            if (input.capture_vim && !input.normal_keys) {
                 for (const char* p = e.text.text; *p; ++p)
                     input.vim_keys.push_back((unsigned char)*p);
             }
diff --git a/src/systems/input_system.hpp b/src/systems/input_system.hpp
index 11fcea2..4fcf1a8 100644
--- a/src/systems/input_system.hpp
+++ b/src/systems/input_system.hpp
@@ -35,8 +35,11 @@ struct FrameInput
     bool right    = false;
     bool pick     = false;
     bool shoot    = false;
+
+    bool normal_keys    = false;
+
     bool capture_typing = false;
-    bool capture_vim = false;
+    bool capture_vim    = false;
 
     bool typing_mode   = false;
     bool typing_commit = false;
diff --git a/src/text/handler.cpp b/src/text/handler.cpp
index 33fb39a..4a2d349 100644
--- a/src/text/handler.cpp
+++ b/src/text/handler.cpp
@@ -989,6 +989,8 @@ Handler::feed_Keys(DynArray<int>& keys, double eye_y)
         if      (k == -1) _vim->key_enter();
         else if (k == -2) _vim->key_escape();
         else if (k == -3) _vim->key_backspace();
+        else if (k == -4) _vim->key_tab();
+        else if (k == -5) _vim->key_redo();
         else if (k  >  0) _vim->key_char(static_cast<char>(k));
     }
 
@@ -1001,8 +1003,9 @@ Handler::feed_Keys(DynArray<int>& keys, double eye_y)
         case VimBuffer::Signal::Help:
             _status = {
                 "-- VIM HELP --",
-                "i a A o O  insert    Esc normal    h j k l move",
-                "0 $  line ends   gg G :N  jump line   x dd  delete",
+                "i a A o O  insert   Esc normal   h j k l move",
+                "0 $  line ends   gg G :N  jump   x dd  delete",
+                "yy yw dw cw cc  yank/change   p P  paste   >> <<  indent",
                 ":w write   :q quit   :wq write+quit   :q! discard",
             };
             break;
diff --git a/src/text/vim_buffer.cpp b/src/text/vim_buffer.cpp
index dbef74e..c4509d6 100644
--- a/src/text/vim_buffer.cpp
+++ b/src/text/vim_buffer.cpp
@@ -15,6 +15,9 @@ VimBuffer::load(const std::vector<std::string>& src)
     _pending.clear();
     _cmd    .clear();
     _status .clear();
+
+    _undo.clear();
+    _redo.clear();
 }
 
 
@@ -50,6 +53,26 @@ void VimBuffer::key_escape()
 }
 
 
+void VimBuffer::key_tab()
+{
+    if (_mode != Mode::Insert) return;
+    constexpr int SW = 4;
+    std::string& L = _lines[(size_t)_row];
+    if (_col > (int)L.size()) _col = (int)L.size();
+    L.insert((size_t)_col, (size_t)SW, ' ');
+    _col += SW;
+    _dirty = true;
+    touch();
+}
+
+
+void VimBuffer::key_redo()
+{
+    if (_mode != Mode::Normal) return;
+    redo();
+}
+
+
 VimBuffer::Signal
 VimBuffer::take_Signal()
 { Signal s = _sig; _sig = Signal::None; return s; }
@@ -69,6 +92,8 @@ VimBuffer::insert_Word_At(int row, int col, const std::string& word)
     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)
@@ -134,9 +159,32 @@ VimBuffer::normal_Key(char c)
     }
 
     if (_pending == "d") { _pending.clear();
-        if (c == 'd') { delete_Lines(get_Eff()); _count = 0;
-                        touch(); return; }
-        _count = 0; return;
+        int eff = get_Eff(); _count = 0;
+        if      (c == 'd') { push_Undo(); yank_Lines(eff); delete_Lines(eff); }
+        else if (c == 'w') { push_Undo(); op_ToNextWord(eff, /*del=*/true, /*yank=*/true); }
+        touch(); return;
+    }
+    if (_pending == "y") { _pending.clear();
+        int eff = get_Eff(); _count = 0;
+        if      (c == 'y') { yank_Lines(eff); }
+        else if (c == 'w') { op_ToNextWord(eff, /*del=*/false, /*yank=*/true); }
+        touch(); return;
+    }
+    if (_pending == "c") { _pending.clear();
+        int eff = get_Eff(); _count = 0;
+        if      (c == 'c') { push_Undo(); yank_Lines(1); change_Line(); }
+        else if (c == 'w') { push_Undo(); op_ChangeWord(eff); }
+        touch(); return;
+    }
+    if (_pending == ">") { _pending.clear();
+        int eff = get_Eff(); _count = 0;
+        if (c == '>') { push_Undo(); indent_Lines(eff, +1); }
+        touch(); return;
+    }
+    if (_pending == "<") { _pending.clear();
+        int eff = get_Eff(); _count = 0;
+        if (c == '<') { push_Undo(); indent_Lines(eff, -1); }
+        touch(); return;
     }
     if (_pending == "g") { _pending.clear();
         if (c == 'g') { _row = get_ClampRow(_count > 0 ? _count - 1 : 0);
@@ -151,15 +199,25 @@ VimBuffer::normal_Key(char c)
         case 'k': _row = get_ClampRow(_row - get_Eff());        break;
         case '0': _col = 0;                                     break;
         case '$': _col = get_LineLen() > 0 ? get_LineLen() - 1 : 0;                    break;
-        case 'x': delete_Chars(get_Eff());                      break;
-        case 'i': _mode = Mode::Insert;                         break;
-        case 'a': _col = (get_LineLen() > 0 ? _col + 1 : 0); _mode = Mode::Insert;     break;
-        case 'A': _col = get_LineLen(); _mode = Mode::Insert;   break;
-        case 'o': open_Below(); _mode = Mode::Insert;           break;
-        case 'O': open_Above(); _mode = Mode::Insert;           break;
+        case 'w': _col = next_WordCol(get_Eff());               break;
+        case 'e': _col = end_WordCol (get_Eff());               break;
+        case 'b': _col = prev_WordCol(get_Eff());               break;
+        case 'x': push_Undo(); delete_Chars(get_Eff());         break;
+        case 'i': push_Undo(); _mode = Mode::Insert;            break;
+        case 'a': push_Undo(); _col = (get_LineLen() > 0 ? _col + 1 : 0); _mode = Mode::Insert;     break;
+        case 'A': push_Undo(); _col = get_LineLen(); _mode = Mode::Insert;   break;
+        case 'o': push_Undo(); open_Below(); _mode = Mode::Insert;           break;
+        case 'O': push_Undo(); open_Above(); _mode = Mode::Insert;           break;
         case 'G': _row = get_ClampRow(_count > 0 ? _count - 1 : (int)_lines.size() - 1);break;
         case 'd': _pending = "d";                               return;
+        case 'y': _pending = "y";                               return;
+        case 'c': _pending = "c";                               return;
         case 'g': _pending = "g";                               return;
+        case '>': _pending = ">";                               return;
+        case '<': _pending = "<";                               return;
+        case 'p': push_Undo(); paste_After();                   break;
+        case 'P': push_Undo(); paste_Before();                  break;
+        case 'u': undo();                                       break;
         default: break;
     }
     _count = 0; clamp_Col(); touch();
@@ -263,3 +321,258 @@ VimBuffer::cancel_Command()
 bool
 VimBuffer::is_Dirty() const
 { return _dirty; }
+
+
+// ── word motion (whitespace-delimited) ───────────────────────────────────────
+
+// Exclusive end column of the word the cursor is in (skips any leading spaces
+// first), advanced `times` words. Used by cw — stops at the word end, does NOT
+// consume trailing whitespace (matches vim's cw == ce behaviour).
+int
+VimBuffer::word_EndCol(int times) const
+{
+    const std::string& L = _lines[(size_t)_row];
+    const int n = (int)L.size();
+    int c = _col;
+    for (int k = 0; k < times; ++k) {
+        while (c < n && L[(size_t)c] == ' ') ++c;   // skip spaces
+        while (c < n && L[(size_t)c] != ' ') ++c;   // to end of word
+    }
+    return c;
+}
+
+// Start column of the `times`-th next word (end-of-line if none). Used by dw/yw:
+// delete/yank spans [_col, next_WordCol), which includes trailing spaces up to
+// the next word (matches vim's dw).
+int
+VimBuffer::next_WordCol(int times) const
+{
+    const std::string& L = _lines[(size_t)_row];
+    const int n = (int)L.size();
+    int c = _col;
+    for (int k = 0; k < times; ++k) {
+        while (c < n && L[(size_t)c] != ' ') ++c;   // skip current word
+        while (c < n && L[(size_t)c] == ' ') ++c;   // skip spaces to next word
+    }
+    return c;
+}
+
+
+// ── register fill: linewise ──────────────────────────────────────────────────
+void
+VimBuffer::yank_Lines(int n)
+{
+    const int end = (_row + n < (int)_lines.size()) ? _row + n : (int)_lines.size();
+    std::string reg;
+    for (int i = _row; i < end; ++i) {
+        reg += _lines[(size_t)i];
+        if (i + 1 < end) reg += '\n';
+    }
+    _register     = reg;
+    _reg_linewise = true;
+}
+
+
+// ── dw / yw: operate on [_col, next_WordCol) charwise ────────────────────────
+void
+VimBuffer::op_ToNextWord(int times, bool del, bool yank)
+{
+    std::string& L = _lines[(size_t)_row];
+    const int from = _col;
+    int to = next_WordCol(times);
+    if (to <= from) return;
+
+    if (yank) { _register = L.substr((size_t)from, (size_t)(to - from)); _reg_linewise = false; }
+    if (del)  { L.erase((size_t)from, (size_t)(to - from)); _dirty = true; clamp_Col(); }
+}
+
+
+// ── cw: change to end of word (charwise), then enter insert ──────────────────
+void
+VimBuffer::op_ChangeWord(int times)
+{
+    std::string& L = _lines[(size_t)_row];
+    const int from = _col;
+    const int to   = word_EndCol(times);
+    if (to > from) {
+        _register     = L.substr((size_t)from, (size_t)(to - from));
+        _reg_linewise = false;
+        L.erase((size_t)from, (size_t)(to - from));
+        _dirty = true;
+    }
+    _mode = Mode::Insert;
+    clamp_Col();
+}
+
+
+// ── cc: clear the current line, keep it, enter insert (register pre-filled) ──
+void
+VimBuffer::change_Line()
+{
+    _lines[(size_t)_row].clear();
+    _col   = 0;
+    _mode  = Mode::Insert;
+    _dirty = true;
+}
+
+
+// ── paste ─────────────────────────────────────────────────────────────────
+namespace {
+std::vector<std::string> split_Lines(const std::string& s)
+{
+    std::vector<std::string> out;
+    std::string cur;
+    for (char c : s) {
+        if (c == '\n') { out.push_back(cur); cur.clear(); }
+        else             cur.push_back(c);
+    }
+    out.push_back(cur);
+    return out;
+}
+} // namespace
+
+void
+VimBuffer::paste_After()
+{
+    if (_register.empty()) return;
+
+    if (_reg_linewise) {
+        std::vector<std::string> rl = split_Lines(_register);
+        const int at = _row + 1;
+        _lines.insert(_lines.begin() + at, rl.begin(), rl.end());
+        _row = at;          // land on first pasted line
+        _col = 0;
+    } else {
+        std::string& L = _lines[(size_t)_row];
+        int at = _col + (L.empty() ? 0 : 1);        // after the cursor char
+        if (at > (int)L.size()) at = (int)L.size();
+        L.insert((size_t)at, _register);
+        _col = at + (int)_register.size() - 1;      // cursor on last pasted char
+    }
+    _dirty = true;
+    clamp_Col();
+    touch();
+}
+
+void
+VimBuffer::paste_Before()
+{
+    if (_register.empty()) return;
+
+    if (_reg_linewise) {
+        std::vector<std::string> rl = split_Lines(_register);
+        _lines.insert(_lines.begin() + _row, rl.begin(), rl.end());
+        _col = 0;           // _row now points at the first pasted line
+    } else {
+        std::string& L = _lines[(size_t)_row];
+        const int at = _col;
+        L.insert((size_t)at, _register);
+        _col = at + (int)_register.size() - 1;
+    }
+    _dirty = true;
+    clamp_Col();
+    touch();
+}
+
+
+// ── indentation: shift line(s) by one shiftwidth ─────────────────────────────
+void
+VimBuffer::indent_Lines(int n, int dir)
+{
+    constexpr int SW = 4;
+    const int end = (_row + n < (int)_lines.size()) ? _row + n : (int)_lines.size();
+
+    for (int i = _row; i < end; ++i) {
+        std::string& L = _lines[(size_t)i];
+        if (dir > 0) {
+            L.insert(0, (size_t)SW, ' ');
+            if (i == _row) _col += SW;
+        } else {
+            int rm = 0;
+            while (rm < SW && rm < (int)L.size() && L[(size_t)rm] == ' ') ++rm;
+            L.erase(0, (size_t)rm);
+            if (i == _row) _col = (_col > rm) ? _col - rm : 0;
+        }
+    }
+    _dirty = true;
+    clamp_Col();
+}
+
+
+// ── e: last char of the n-th word forward (always advances at least one) ─────
+int
+VimBuffer::end_WordCol(int times) const
+{
+    const std::string& L = _lines[(size_t)_row];
+    const int n = (int)L.size();
+    if (n == 0) return 0;
+    int c = _col;
+    for (int k = 0; k < times; ++k) {
+        if (c < n) ++c;                                   // step off current char
+        while (c < n && L[(size_t)c] == ' ') ++c;         // skip spaces
+        while (c + 1 < n && L[(size_t)(c + 1)] != ' ') ++c; // to last char of word
+    }
+    return (c < n) ? c : n - 1;
+}
+
+
+// ── b: start of the n-th word backward (always retreats at least one) ────────
+int
+VimBuffer::prev_WordCol(int times) const
+{
+    const std::string& L = _lines[(size_t)_row];
+    int c = _col;
+    for (int k = 0; k < times; ++k) {
+        if (c > 0) --c;                                   // step off current char
+        while (c > 0 && L[(size_t)c] == ' ') --c;         // skip spaces backward
+        while (c > 0 && L[(size_t)(c - 1)] != ' ') --c;   // to first char of word
+    }
+    return (c > 0) ? c : 0;
+}
+
+
+// ── undo/redo: full-state snapshot stacks ────────────────────────────────────
+void
+VimBuffer::push_Undo()
+{
+    _undo.push_back({ _lines, _row, _col });
+    _redo.clear();                       // a new edit invalidates the redo branch
+
+    constexpr size_t LIMIT = 200;        // bound memory on a long session
+    if (_undo.size() > LIMIT)
+        _undo.erase(_undo.begin());
+}
+
+void
+VimBuffer::undo()
+{
+    if (_undo.empty()) { _status = "Already at oldest change"; return; }
+
+    _redo.push_back({ _lines, _row, _col });
+    Snapshot s = _undo.back(); _undo.pop_back();
+
+    _lines = std::move(s.lines);
+    _row   = get_ClampRow(s.row);
+    _col   = s.col;
+    _mode  = Mode::Normal;
+    _dirty = true;
+    clamp_Col();
+    touch();
+}
+
+void
+VimBuffer::redo()
+{
+    if (_redo.empty()) { _status = "Already at newest change"; return; }
+
+    _undo.push_back({ _lines, _row, _col });
+    Snapshot s = _redo.back(); _redo.pop_back();
+
+    _lines = std::move(s.lines);
+    _row   = get_ClampRow(s.row);
+    _col   = s.col;
+    _mode  = Mode::Normal;
+    _dirty = true;
+    clamp_Col();
+    touch();
+}
diff --git a/src/text/vim_buffer.hpp b/src/text/vim_buffer.hpp
index f8919b0..2856784 100644
--- a/src/text/vim_buffer.hpp
+++ b/src/text/vim_buffer.hpp
@@ -21,15 +21,14 @@ public:
     void key_enter    ();
     void key_backspace();
     void key_escape   ();
+    void key_tab      ();
+    void key_redo     ();
 
     void   load       (const std::vector<std::string>& src);
     Signal take_Signal();
     void   mark_Saved ();
 
-    // 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 is_Empty() const { return _lines.size() == 1 && _lines[0].empty(); }
@@ -48,15 +47,22 @@ public:
     void start_Insert() { _mode = Mode::Insert; _col = 0; 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;
 
+    std::string _register;
+    bool        _reg_linewise = false;
+
+    struct Snapshot { std::vector<std::string> lines; int row; int col; };
+    std::vector<Snapshot> _undo;
+    std::vector<Snapshot> _redo;
+
     Mode     _mode = Mode::Normal;
     Signal   _sig = Signal::None;
     std::vector<std::string> _saved;
@@ -75,6 +81,24 @@ 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;
+
+    void push_Undo       ();
+    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     ();
+    void paste_After     ();
+    void paste_Before    ();
+
+    void indent_Lines    (int n, int dir);
+
     bool is_Dirty        ()      const;
     int  get_LineLen     ()      const;
     int  get_Eff         ()      const;