sdl_runtime_compiler git · main
SDL3 game for running and compiling code at runtime
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.gzAdd focus wall jump And cursor to crosshair pos With Claude
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;