aboutsummaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java27
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/actor/GridBoardActor.java2
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/config/Settings.java4
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/GameScreen.java8
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java9
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/game/GameScreenWorld.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/InputSystem.java9
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/RenderSystem.java32
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/CursorStyle.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorModifier.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorTextArea.java274
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/InputStrategy.java11
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/KeyEvent.java9
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LambdaEditor.java80
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LineNumberMode.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/RenderState.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/EditorBuffer.java21
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/Mode.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimConfig.java34
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngine.java205
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/buffer/TextAreaBuffer.java64
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/context/KeyContext.java706
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/CommandAction.java8
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/HandleResult.java32
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/InsertModeHandler.java24
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/ModeHandler.java10
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/MotionAction.java7
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/NormalModeHandler.java326
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/SearchModeHandler.java42
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualLineModeHandler.java92
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualModeHandler.java132
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/Register.java11
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/RegisterType.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoManager.java52
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoState.java11
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/navigation/TextNavigation.java110
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchDirection.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchIterator.java58
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/InsertState.java4
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/ModeState.java15
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/NormalState.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/PendingOperator.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/SearchState.java11
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualLineState.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualState.java5
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/status/StatusFormatter.java56
-rw-r--r--core/src/main/java/coffee/liz/abstractionengine/app/utils/FontHelper.java32
-rw-r--r--core/src/main/java/com/kotcrab/vis/ui/widget/EditorHighlightTextAreaBridge.java33
-rw-r--r--core/src/test/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngineTest.java578
49 files changed, 3161 insertions, 38 deletions
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
index 8c418af..9aa61ec 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/AbstractionEngineGame.java
@@ -2,21 +2,18 @@ package coffee.liz.abstractionengine.app;
import coffee.liz.abstractionengine.app.screen.ScrollLogo;
import coffee.liz.abstractionengine.app.config.Settings;
+import coffee.liz.abstractionengine.app.utils.FontHelper;
import coffee.liz.ecs.math.Vec2;
import coffee.liz.ecs.math.Vec2f;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
-import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
-import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.utils.viewport.FitViewport;
public class AbstractionEngineGame extends Game {
- public static final Vec2<Float> WORLD_SIZE = Vec2f.builder().x(200f).y(200f).build();
- private static final String RENDERABLE_CHARS = "λ" + FreeTypeFontGenerator.DEFAULT_CHARS;
- private static final String FONT = "fonts/JetBrainsMonoNerdFont-Regular.ttf";
+ public static final Vec2<Float> WORLD_SIZE = Vec2f.builder().x(800f).y(800f).build();
public SpriteBatch batch;
public BitmapFont font;
@@ -31,7 +28,8 @@ public class AbstractionEngineGame extends Game {
viewport = new FitViewport(WORLD_SIZE.getX(), WORLD_SIZE.getY());
batch = new SpriteBatch();
shapeRenderer = new ShapeRenderer();
- font = initFont(24, FONT);
+ font = FontHelper.initFont(24, FontHelper.MONO,
+ AbstractionEngineGame.WORLD_SIZE.getY() / Gdx.graphics.getHeight());
this.setScreen(new ScrollLogo(this));
@@ -53,21 +51,4 @@ public class AbstractionEngineGame extends Game {
font.dispose();
shapeRenderer.dispose();
}
-
- private BitmapFont initFont(final int size, final String path) {
- final int scaleForHidpi = 2;
- final FreeTypeFontGenerator gen = new FreeTypeFontGenerator(Gdx.files.internal(path));
- final FreeTypeFontGenerator.FreeTypeFontParameter params = new FreeTypeFontGenerator.FreeTypeFontParameter();
- params.characters = RENDERABLE_CHARS;
- params.size = scaleForHidpi * size;
-
- final BitmapFont font = gen.generateFont(params);
- font.setFixedWidthGlyphs(RENDERABLE_CHARS);
- font.setUseIntegerPositions(false);
- font.getData().setScale((1f / scaleForHidpi) * WORLD_SIZE.getY() / Gdx.graphics.getHeight());
- font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
-
- gen.dispose();
- return font;
- }
}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/actor/GridBoardActor.java b/core/src/main/java/coffee/liz/abstractionengine/app/actor/GridBoardActor.java
index 3c7a82a..a4317e0 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/actor/GridBoardActor.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/actor/GridBoardActor.java
@@ -35,7 +35,7 @@ public class GridBoardActor extends Actor {
if (cellSize != null) {
final float dotRadius = Math.min(cellSize.getX(), cellSize.getY()) * 0.07f;
- shapeRenderer.setColor(Theme.BG_PATTERN);
+ shapeRenderer.setColor(Theme.BORDER);
Mat2.init(gridSize, pos -> {
final Vec2<Float> center = pos.floatValue().plus(Vec2f.builder().x(0.5f).y(0.5f).build())
.scale(cellSize);
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/config/Settings.java b/core/src/main/java/coffee/liz/abstractionengine/app/config/Settings.java
index 01d84a3..dd09c21 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/config/Settings.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/config/Settings.java
@@ -9,9 +9,11 @@ import lombok.Getter;
@Getter
public class Settings {
@Builder.Default
- private boolean skipIntro = false;
+ private boolean skipIntro = true;
@Builder.Default
private boolean playMusic = true;
+ @Builder.Default
+ private boolean vimMode = true;
@Builder.Default
private KeyBinds keyBinds = KeyBinds.builder().build();
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/GameScreen.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/GameScreen.java
index 3ddef91..cbdf51a 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/screen/GameScreen.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/GameScreen.java
@@ -4,6 +4,7 @@ import coffee.liz.abstractionengine.app.AbstractionEngineGame;
import coffee.liz.abstractionengine.app.screen.game.FrameState;
import coffee.liz.abstractionengine.app.screen.game.GameScreenWorld;
import coffee.liz.abstractionengine.app.screen.game.system.GameWorldManagementSystem;
+import coffee.liz.abstractionengine.app.screen.game.system.RenderSystem;
import coffee.liz.abstractionengine.app.utils.FunctionUtils;
import coffee.liz.ecs.math.Vec2;
import coffee.liz.ecs.math.Vec2i;
@@ -21,13 +22,17 @@ public class GameScreen implements Screen {
private GameScreenWorld outerWorld;
private FitViewport viewport;
+ private RenderSystem renderSystem;
@Override
public void show() {
viewport = new FitViewport(AbstractionEngineGame.WORLD_SIZE.getX(), AbstractionEngineGame.WORLD_SIZE.getY());
- outerWorld = new GameScreenWorld(GAME_GRID_SIZE, viewport);
+ outerWorld = new GameScreenWorld(GAME_GRID_SIZE, viewport, game.settings);
outerWorld.getSystem(GameWorldManagementSystem.class).populateLevel();
+ renderSystem = outerWorld.getSystem(RenderSystem.class);
+ Gdx.input.setInputProcessor(renderSystem.getInputProcessor());
viewport.update(Gdx.graphics.getWidth(), Gdx.graphics.getHeight(), true);
+ renderSystem.resize(Gdx.graphics.getWidth(), Gdx.graphics.getHeight());
render(0);
}
@@ -42,6 +47,7 @@ public class GameScreen implements Screen {
@Override
public void resize(final int width, final int height) {
viewport.update(width, height, true);
+ renderSystem.resize(width, height);
}
@Override
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
index 01aa4e8..30cc01f 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/MainMenu.java
@@ -31,8 +31,7 @@ import java.util.Set;
public class MainMenu implements Screen {
public static final Vec2<Integer> GRID_DIMENSIONS = Vec2i.builder().x(50).y(50).build();
- private static final float BUTTON_WIDTH = 50f;
- private static final float BUTTON_HEIGHT = 12f;
+ private static final Vec2<Float> BUTTON_SIZE = AbstractionEngineGame.WORLD_SIZE.scale(1 / 4f, 1 / 15f);
private static final float PARALLAX_STRENGTH = 3f;
private final World<LifeInput> world = new DAGWorld<>(Map.of(LifeSystem.class, new LifeSystem(GRID_DIMENSIONS),
@@ -57,8 +56,8 @@ public class MainMenu implements Screen {
lifeGridActor.setViewport(game.viewport);
stage.addActor(lifeGridActor);
- final Button playButton = createButton("play", (game.viewport.getWorldWidth() - BUTTON_WIDTH) / 2f,
- (game.viewport.getWorldHeight() - BUTTON_HEIGHT) / 2f);
+ final Button playButton = createButton("play", (game.viewport.getWorldWidth() - BUTTON_SIZE.getX()) / 2f,
+ (game.viewport.getWorldHeight() - BUTTON_SIZE.getY()) / 2f);
playButton.setOnClick(() -> game.setScreen(new GameScreen(game)));
stage.addActor(playButton);
@@ -68,7 +67,7 @@ public class MainMenu implements Screen {
private Button createButton(final String text, final float x, final float y) {
final Button button = new Button(game.shapeRenderer, game.font, text);
button.setPosition(x, y);
- button.setSize(BUTTON_WIDTH, BUTTON_HEIGHT);
+ button.setSize(BUTTON_SIZE.getX(), BUTTON_SIZE.getY());
button.addButtonListener();
return button;
}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/GameScreenWorld.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/GameScreenWorld.java
index f6385f6..a0c0b5b 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/GameScreenWorld.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/GameScreenWorld.java
@@ -1,5 +1,6 @@
package coffee.liz.abstractionengine.app.screen.game;
+import coffee.liz.abstractionengine.app.config.Settings;
import coffee.liz.abstractionengine.app.screen.game.system.GameWorldManagementSystem;
import coffee.liz.abstractionengine.app.screen.game.system.GridInterpolationSystem;
import coffee.liz.abstractionengine.app.screen.game.system.InputSystem;
@@ -11,9 +12,9 @@ import com.badlogic.gdx.utils.viewport.FitViewport;
import java.util.Map;
public class GameScreenWorld extends DAGWorld<FrameState> {
- public GameScreenWorld(final Vec2<Integer> gridSize, final FitViewport viewport) {
+ public GameScreenWorld(final Vec2<Integer> gridSize, final FitViewport viewport, final Settings settings) {
super(Map.of(InputSystem.class, new InputSystem(), GameWorldManagementSystem.class,
new GameWorldManagementSystem(gridSize), GridInterpolationSystem.class, new GridInterpolationSystem(),
- RenderSystem.class, new RenderSystem(viewport, gridSize)));
+ RenderSystem.class, new RenderSystem(viewport, gridSize, settings)));
}
}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/InputSystem.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/InputSystem.java
index b3af099..b311221 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/InputSystem.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/InputSystem.java
@@ -4,6 +4,7 @@ import coffee.liz.abstractionengine.app.config.KeyBinds;
import coffee.liz.abstractionengine.app.screen.game.FrameState;
import coffee.liz.ecs.math.Vec2;
import coffee.liz.ecs.math.Vec2i;
+import coffee.liz.ecs.model.System;
import coffee.liz.ecs.model.World;
import lombok.Getter;
@@ -11,9 +12,9 @@ import java.time.Duration;
import java.util.Collection;
import java.util.Set;
-public class InputSystem implements coffee.liz.ecs.model.System<FrameState> {
- private static final Duration INITIAL_DELAY = Duration.ofMillis(300);
- private static final Duration REPEAT_INTERVAL = Duration.ofMillis(100);
+public class InputSystem implements System<FrameState> {
+ private static final Duration INITIAL_DELAY = Duration.ofMillis(200);
+ private static final Duration REPEAT_INTERVAL = Duration.ofMillis(80);
private KeyBinds.Action heldAction = null;
private Duration heldTime = Duration.ZERO;
@@ -23,7 +24,7 @@ public class InputSystem implements coffee.liz.ecs.model.System<FrameState> {
private Vec2<Integer> stepMovement = Vec2i.ZERO;
@Override
- public Collection<Class<? extends coffee.liz.ecs.model.System<FrameState>>> getDependencies() {
+ public Collection<Class<? extends System<FrameState>>> getDependencies() {
return Set.of();
}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/RenderSystem.java b/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/RenderSystem.java
index 344271f..9a839d8 100644
--- a/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/RenderSystem.java
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/screen/game/system/RenderSystem.java
@@ -2,17 +2,23 @@ package coffee.liz.abstractionengine.app.screen.game.system;
import coffee.liz.abstractionengine.app.Theme;
import coffee.liz.abstractionengine.app.actor.GridBoardActor;
+import coffee.liz.abstractionengine.app.config.Settings;
import coffee.liz.abstractionengine.app.screen.game.FrameState;
import coffee.liz.abstractionengine.app.screen.game.component.InterpolatedPosition;
+import coffee.liz.abstractionengine.app.ui.editor.LambdaEditor;
import coffee.liz.abstractionengine.entity.EntityType;
import coffee.liz.ecs.math.Vec2;
import coffee.liz.ecs.math.Vec2f;
import coffee.liz.ecs.model.System;
import coffee.liz.ecs.model.World;
+import com.badlogic.gdx.InputMultiplexer;
+import com.badlogic.gdx.InputProcessor;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.scenes.scene2d.Stage;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.FitViewport;
+import com.badlogic.gdx.utils.viewport.ScreenViewport;
+import com.kotcrab.vis.ui.VisUI;
import java.time.Duration;
import java.util.Collection;
@@ -24,15 +30,33 @@ public class RenderSystem implements System<FrameState> {
private final Vec2<Integer> gridSize;
private final ShapeRenderer shapeRenderer;
private final Stage stage;
+ private final Stage uiStage;
private final GridBoardActor boardActor;
+ private final InputMultiplexer inputMultiplexer;
- public RenderSystem(final FitViewport viewport, final Vec2<Integer> gridSize) {
+ public RenderSystem(final FitViewport viewport, final Vec2<Integer> gridSize, final Settings settings) {
this.viewport = viewport;
this.gridSize = gridSize;
this.shapeRenderer = new ShapeRenderer();
this.stage = new Stage(viewport);
this.boardActor = new GridBoardActor(shapeRenderer, gridSize);
+
stage.addActor(boardActor);
+
+ VisUI.load();
+ this.uiStage = new Stage(new ScreenViewport());
+ uiStage.addActor(new LambdaEditor(settings.isVimMode()));
+
+ // TODO: Move this to an input system
+ this.inputMultiplexer = new InputMultiplexer(uiStage, stage);
+ }
+
+ public InputProcessor getInputProcessor() {
+ return inputMultiplexer;
+ }
+
+ public void resize(final int width, final int height) {
+ uiStage.getViewport().update(width, height, true);
}
@Override
@@ -59,12 +83,18 @@ public class RenderSystem implements System<FrameState> {
final float deltaSeconds = dt.toMillis() / 1000f;
stage.act(deltaSeconds);
stage.draw();
+
+ uiStage.getViewport().apply();
+ uiStage.act(deltaSeconds);
+ uiStage.draw();
}
@Override
public void close() {
+ uiStage.dispose();
stage.dispose();
shapeRenderer.dispose();
+ VisUI.dispose();
}
private Vec2<Float> computeCellSize() {
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/CursorStyle.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/CursorStyle.java
new file mode 100644
index 0000000..c8b82af
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/CursorStyle.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+public enum CursorStyle {
+ CARET, BLOCK, CARET_AND_BLOCK, HIDDEN
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorModifier.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorModifier.java
new file mode 100644
index 0000000..fa9dd00
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorModifier.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+public enum EditorModifier {
+ CTRL, ALT, SHIFT, META
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorTextArea.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorTextArea.java
new file mode 100644
index 0000000..e2570b4
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/EditorTextArea.java
@@ -0,0 +1,274 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+import coffee.liz.abstractionengine.app.Theme;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.Input;
+import com.badlogic.gdx.graphics.Color;
+import com.badlogic.gdx.graphics.GL20;
+import com.badlogic.gdx.graphics.g2d.Batch;
+import com.badlogic.gdx.graphics.g2d.BitmapFont;
+import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
+import com.badlogic.gdx.scenes.scene2d.InputEvent;
+import com.badlogic.gdx.scenes.scene2d.InputListener;
+import com.badlogic.gdx.scenes.scene2d.Stage;
+import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane;
+import com.badlogic.gdx.scenes.scene2d.utils.BaseDrawable;
+import com.badlogic.gdx.scenes.scene2d.utils.Drawable;
+import com.kotcrab.vis.ui.widget.EditorHighlightTextAreaBridge;
+import lombok.Setter;
+
+import java.util.EnumSet;
+import java.util.Set;
+
+public class EditorTextArea extends EditorHighlightTextAreaBridge {
+ private static final Color CURRENT_LINE_COLOR = new Color(Theme.SURFACE_ALT.r, Theme.SURFACE_ALT.g,
+ Theme.SURFACE_ALT.b, 0.5f);
+ private static final Color BLOCK_CURSOR_COLOR = new Color(Theme.FG.r, Theme.FG.g, Theme.FG.b, 0.7f);
+ private static final Set<EditorModifier> EMPTY_MODIFIERS = Set.of();
+ private static final Drawable INVISIBLE_CURSOR = new BaseDrawable() {
+ };
+
+ private final float gutterWidth;
+ private final float charWidth;
+ private final ShapeRenderer shapeRenderer;
+ @Setter
+ private InputStrategy inputStrategy;
+
+ public EditorTextArea(final String text, final VisTextFieldStyle style, final float gutterWidth) {
+ super(text, padStyle(style, gutterWidth));
+ this.gutterWidth = gutterWidth;
+ this.charWidth = style.font.getData().spaceXadvance;
+ this.shapeRenderer = new ShapeRenderer();
+ }
+
+ public void syncCursorState() {
+ editorSyncCursorState();
+ scrollCursorIntoView();
+ }
+
+ public void setSelectionEndpoints(final int selectionStart, final int cursorPosition) {
+ editorSetSelectionEndpoints(selectionStart, cursorPosition);
+ scrollCursorIntoView();
+ }
+
+ public void setCursorKeepingSelection(final int cursorPosition) {
+ editorSetCursorKeepingSelection(cursorPosition);
+ scrollCursorIntoView();
+ }
+
+ @Override
+ protected InputListener createInputListener() {
+ return new TextAreaListener() {
+ @Override
+ public boolean keyDown(final InputEvent event, final int keycode) {
+ if (inputStrategy != null) {
+ final KeyEvent keyEvent = new KeyEvent(keycode, currentModifiers());
+ if (inputStrategy.onKeyDown(keyEvent)) {
+ return true;
+ }
+ }
+ return super.keyDown(event, keycode);
+ }
+
+ @Override
+ public boolean keyTyped(final InputEvent event, final char character) {
+ if (inputStrategy != null && inputStrategy.onKeyTyped(character)) {
+ return true;
+ }
+ return super.keyTyped(event, character);
+ }
+
+ @Override
+ public void clicked(final InputEvent event, final float x, final float y) {
+ final int count = getTapCount() % 4;
+ if (count == 0) {
+ clearSelection();
+ }
+ if (count == 2) {
+ final Drawable bg = getStyle().background;
+ final float adjustedX = bg != null ? Math.max(0, x - bg.getLeftWidth()) : x;
+ final int[] word = wordUnderCursor(letterUnderCursor(adjustedX));
+ setSelection(word[0], word[1]);
+ }
+ }
+ };
+ }
+
+ @Override
+ protected void drawCursor(final Drawable cursorPatch, final Batch batch, final BitmapFont font, final float x,
+ final float y) {
+ if (shouldRenderBlockCursor()) {
+ if (shouldRenderCaret()) {
+ super.drawCursor(cursorPatch, batch, font, x, y);
+ } else {
+ super.drawCursor(INVISIBLE_CURSOR, batch, font, x, y);
+ }
+ return;
+ }
+
+ if (shouldRenderCaret()) {
+ super.drawCursor(cursorPatch, batch, font, x, y);
+ }
+ }
+
+ @Override
+ protected void drawText(final Batch batch, final BitmapFont font, final float x, final float y) {
+ final float parentAlpha = font.getColor().a;
+ final int first = getFirstLineShowing();
+ final int count = getLinesShowing();
+ final int total = getLineNumberCount();
+ final int cursorLine = getCursorLine();
+ final float lineHeight = font.getLineHeight();
+
+ batch.end();
+ Gdx.gl.glEnable(GL20.GL_BLEND);
+ shapeRenderer.setProjectionMatrix(batch.getProjectionMatrix());
+ shapeRenderer.setTransformMatrix(batch.getTransformMatrix());
+ shapeRenderer.begin(ShapeRenderer.ShapeType.Filled);
+ if (cursorLine >= first && cursorLine < first + count) {
+ final float highlightY = y - font.getCapHeight() + font.getDescent() - (cursorLine - first) * lineHeight;
+ shapeRenderer.setColor(CURRENT_LINE_COLOR.r, CURRENT_LINE_COLOR.g, CURRENT_LINE_COLOR.b,
+ CURRENT_LINE_COLOR.a * parentAlpha);
+ shapeRenderer.rect(x - gutterWidth, highlightY, getWidth(), lineHeight);
+ }
+
+ if (shouldRenderBlockCursor() && isFocused()) {
+ // Keep VisTextArea cursor metrics current even when caret is hidden.
+ super.drawCursor(INVISIBLE_CURSOR, batch, font, x, y);
+ drawBlockCursor(font, x, y, first, cursorLine, lineHeight, parentAlpha);
+ }
+ shapeRenderer.end();
+ Gdx.gl.glDisable(GL20.GL_BLEND);
+ batch.begin();
+
+ drawLineNumbers(batch, font, x, y, first, count, total, cursorLine, lineHeight, parentAlpha);
+
+ font.setColor(1, 1, 1, parentAlpha);
+ super.drawText(batch, font, x, y);
+ }
+
+ private static VisTextFieldStyle padStyle(final VisTextFieldStyle original, final float gutterWidth) {
+ final VisTextFieldStyle style = new VisTextFieldStyle(original);
+ style.background = wrapIfPresent(style.background, gutterWidth);
+ style.backgroundOver = wrapIfPresent(style.backgroundOver, gutterWidth);
+ style.focusedBackground = wrapIfPresent(style.focusedBackground, gutterWidth);
+ style.disabledBackground = wrapIfPresent(style.disabledBackground, gutterWidth);
+ return style;
+ }
+
+ private static Drawable wrapIfPresent(final Drawable drawable, final float gutterWidth) {
+ return drawable != null ? new GutterPaddedDrawable(drawable, gutterWidth) : null;
+ }
+
+ private void drawLineNumbers(final Batch batch, final BitmapFont font, final float x, final float y,
+ final int firstLine, final int visibleLineCount, final int totalLines, final int cursorLine,
+ final float lineHeight, final float parentAlpha) {
+ final LineNumberMode mode = resolveRenderState().lineNumberMode();
+ if (mode == LineNumberMode.OFF) {
+ return;
+ }
+
+ float offsetY = 0f;
+ for (int line = firstLine; line < firstLine + visibleLineCount && line < totalLines; line++) {
+ final Color color = line == cursorLine ? Theme.FG : Theme.BORDER_LIGHT;
+ font.setColor(color.r, color.g, color.b, color.a * parentAlpha);
+ final String lineNum = formatLineNumber(mode, line, cursorLine);
+ font.draw(batch, lineNum, x - gutterWidth, y + offsetY);
+ offsetY -= lineHeight;
+ }
+ }
+
+ private int getLineNumberCount() {
+ final int totalLines = getLines();
+ if (totalLines > 1 && getText().endsWith("\n")) {
+ return totalLines - 1;
+ }
+ return totalLines;
+ }
+
+ private String formatLineNumber(final LineNumberMode mode, final int line, final int cursorLine) {
+ if (mode == LineNumberMode.RELATIVE && line != cursorLine) {
+ return String.valueOf(Math.abs(line - cursorLine));
+ }
+ return String.valueOf(line + 1);
+ }
+
+ private void drawBlockCursor(final BitmapFont font, final float x, final float y, final int firstLine,
+ final int cursorLine, final float lineHeight, final float parentAlpha) {
+ final float cursorX = x + getCursorX();
+ final float cursorY = y - font.getCapHeight() + font.getDescent() - (cursorLine - firstLine) * lineHeight;
+ shapeRenderer.setColor(BLOCK_CURSOR_COLOR.r, BLOCK_CURSOR_COLOR.g, BLOCK_CURSOR_COLOR.b,
+ BLOCK_CURSOR_COLOR.a * parentAlpha);
+ shapeRenderer.rect(cursorX, cursorY, Math.max(1f, charWidth), lineHeight);
+ }
+
+ private boolean shouldRenderCaret() {
+ final CursorStyle cursorStyle = resolveRenderState().cursorStyle();
+ return cursorStyle == CursorStyle.CARET || cursorStyle == CursorStyle.CARET_AND_BLOCK;
+ }
+
+ private boolean shouldRenderBlockCursor() {
+ final CursorStyle cursorStyle = resolveRenderState().cursorStyle();
+ return cursorStyle == CursorStyle.BLOCK || cursorStyle == CursorStyle.CARET_AND_BLOCK;
+ }
+
+ private RenderState resolveRenderState() {
+ if (inputStrategy == null) {
+ return RenderState.DEFAULT;
+ }
+ final RenderState renderState = inputStrategy.getRenderState();
+ return renderState != null ? renderState : RenderState.DEFAULT;
+ }
+
+ private boolean isFocused() {
+ final Stage stage = getStage();
+ return stage != null && stage.getKeyboardFocus() == this && !isDisabled();
+ }
+
+ private void scrollCursorIntoView() {
+ if (!(getParent() instanceof ScrollPane scrollPane)) {
+ return;
+ }
+
+ final float lineHeight = getStyle().font.getLineHeight();
+ final float cursorX = getCursorX();
+ final float cursorY = getHeight() - getCursorY() + lineHeight;
+ scrollPane.scrollTo(cursorX, cursorY, Math.max(1f, charWidth), lineHeight, false, false);
+ }
+
+ private Set<EditorModifier> currentModifiers() {
+ if (Gdx.input == null) {
+ return EMPTY_MODIFIERS;
+ }
+
+ final EnumSet<EditorModifier> modifiers = EnumSet.noneOf(EditorModifier.class);
+ if (Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) || Gdx.input.isKeyPressed(Input.Keys.CONTROL_RIGHT)) {
+ modifiers.add(EditorModifier.CTRL);
+ }
+ if (Gdx.input.isKeyPressed(Input.Keys.ALT_LEFT) || Gdx.input.isKeyPressed(Input.Keys.ALT_RIGHT)) {
+ modifiers.add(EditorModifier.ALT);
+ }
+ if (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) || Gdx.input.isKeyPressed(Input.Keys.SHIFT_RIGHT)) {
+ modifiers.add(EditorModifier.SHIFT);
+ }
+ if (Gdx.input.isKeyPressed(Input.Keys.SYM)) {
+ modifiers.add(EditorModifier.META);
+ }
+ return modifiers.isEmpty() ? EMPTY_MODIFIERS : modifiers;
+ }
+
+ private static final class GutterPaddedDrawable extends BaseDrawable {
+ private final Drawable wrapped;
+
+ GutterPaddedDrawable(final Drawable wrapped, final float gutterWidth) {
+ super(wrapped);
+ this.wrapped = wrapped;
+ setLeftWidth(wrapped.getLeftWidth() + gutterWidth);
+ }
+
+ @Override
+ public void draw(final Batch batch, final float x, final float y, final float width, final float height) {
+ wrapped.draw(batch, x, y, width, height);
+ }
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/InputStrategy.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/InputStrategy.java
new file mode 100644
index 0000000..49816d3
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/InputStrategy.java
@@ -0,0 +1,11 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+public interface InputStrategy {
+ boolean onKeyDown(KeyEvent event);
+
+ boolean onKeyTyped(char character);
+
+ default RenderState getRenderState() {
+ return RenderState.DEFAULT;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/KeyEvent.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/KeyEvent.java
new file mode 100644
index 0000000..4da2f60
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/KeyEvent.java
@@ -0,0 +1,9 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+import java.util.Set;
+
+public record KeyEvent(int keycode, Set<EditorModifier> modifiers) {
+ public boolean hasModifier(final EditorModifier modifier) {
+ return modifiers.contains(modifier);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LambdaEditor.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LambdaEditor.java
new file mode 100644
index 0000000..46fcb22
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LambdaEditor.java
@@ -0,0 +1,80 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+import coffee.liz.abstractionengine.app.Theme;
+import coffee.liz.abstractionengine.app.ui.editor.vim.VimEngine;
+import coffee.liz.abstractionengine.app.utils.FontHelper;
+import com.badlogic.gdx.graphics.g2d.BitmapFont;
+import com.badlogic.gdx.graphics.g2d.GlyphLayout;
+import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane;
+import com.badlogic.gdx.utils.Align;
+import com.kotcrab.vis.ui.VisUI;
+import com.kotcrab.vis.ui.util.TableUtils;
+import com.kotcrab.vis.ui.util.highlight.Highlighter;
+import com.kotcrab.vis.ui.widget.VisLabel;
+import com.kotcrab.vis.ui.widget.VisTextField.VisTextFieldStyle;
+import com.kotcrab.vis.ui.widget.VisWindow;
+
+public class LambdaEditor extends VisWindow {
+ private static final int FONT_SIZE = 15;
+ private static final float WINDOW_ALPHA = 0.92f;
+
+ private static final String DEFAULT_TEXT = "-- Church numerals\n" + "let zero = λf.λx.x;\n"
+ + "let one = λf.λx.f x;\n" + "let succ = λn.λf.λx.f (n f x);\n" + "let add = λm.λn.λf.λx.m f (n f x);\n"
+ + "\n" + "succ (add one zero)\n";
+
+ private final BitmapFont font;
+
+ public LambdaEditor(final boolean vimMode) {
+ super("λ-editor");
+
+ TableUtils.setSpacingDefaults(this);
+ columnDefaults(0).left();
+
+ this.font = FontHelper.initFont(FONT_SIZE, FontHelper.MONO, 1f);
+
+ setResizable(true);
+ addCloseButton();
+ addEditor(vimMode);
+
+ setSize(400, 300);
+ setColor(1, 1, 1, WINDOW_ALPHA);
+ centerWindow();
+ }
+
+ private void addEditor(final boolean vimMode) {
+ final VisTextFieldStyle style = new VisTextFieldStyle(VisUI.getSkin().get("textArea", VisTextFieldStyle.class));
+ style.font = font;
+
+ final GlyphLayout layout = new GlyphLayout(font, "000 ");
+ final float gutterWidth = layout.width;
+
+ final EditorTextArea textArea = new EditorTextArea(DEFAULT_TEXT, style, gutterWidth);
+ textArea.setHighlighter(createHighlighter());
+
+ final ScrollPane scrollPane = textArea.createCompatibleScrollPane();
+ scrollPane.setFadeScrollBars(false);
+
+ final VisLabel statusLabel = new VisLabel(vimMode ? "-- NORMAL --" : "-- INSERT --");
+ statusLabel.setAlignment(Align.left);
+
+ if (vimMode) {
+ final VimEngine controller = new VimEngine(textArea, statusLabel::setText);
+ textArea.setInputStrategy(controller);
+ statusLabel.setText(controller.getStatusText());
+ }
+
+ add(scrollPane).grow().row();
+ add(statusLabel).growX().padTop(6f);
+ }
+
+ private static Highlighter createHighlighter() {
+ final Highlighter highlighter = new Highlighter();
+ highlighter.regex(Theme.MUTED, "--[^\\n]*");
+ highlighter.regex(Theme.PRIMARY, "\\blet\\b");
+ highlighter.regex(Theme.SECONDARY, "[λ\\\\]");
+ highlighter.regex(Theme.SECONDARY, "\\.");
+ highlighter.regex(Theme.BORDER_LIGHT, "[=;]");
+ highlighter.regex(Theme.BORDER_LIGHT, "[()]");
+ return highlighter;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LineNumberMode.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LineNumberMode.java
new file mode 100644
index 0000000..3ec768d
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/LineNumberMode.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+public enum LineNumberMode {
+ OFF, ABSOLUTE, RELATIVE
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/RenderState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/RenderState.java
new file mode 100644
index 0000000..a04c988
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/RenderState.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor;
+
+public record RenderState(CursorStyle cursorStyle, LineNumberMode lineNumberMode) {
+ public static final RenderState DEFAULT = new RenderState(CursorStyle.CARET, LineNumberMode.ABSOLUTE);
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/EditorBuffer.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/EditorBuffer.java
new file mode 100644
index 0000000..eadc8e8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/EditorBuffer.java
@@ -0,0 +1,21 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim;
+
+public interface EditorBuffer {
+ String getText();
+
+ void setText(String text);
+
+ int getCursorPosition();
+
+ void setCursorPosition(int cursorPosition);
+
+ int getCursorLine();
+
+ int getLines();
+
+ void moveCursorLine(int line);
+
+ void setSelectionEndpoints(int selectionStart, int cursorPosition);
+
+ void clearSelection();
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/Mode.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/Mode.java
new file mode 100644
index 0000000..e470f91
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/Mode.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim;
+
+public enum Mode {
+ NORMAL, INSERT, VISUAL, VISUAL_LINE, SEARCH
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimConfig.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimConfig.java
new file mode 100644
index 0000000..050790c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimConfig.java
@@ -0,0 +1,34 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim;
+
+public record VimConfig(int maxUndoStates, char ctrlRedoCharacter, String normalStatusLabel, String insertStatusLabel,
+ String visualStatusLabel, String visualLineStatusLabel, boolean wrapSearch) {
+ private static final int DEFAULT_MAX_UNDO_STATES = 200;
+ private static final char DEFAULT_CTRL_REDO_CHARACTER = 18;
+ private static final String DEFAULT_NORMAL_STATUS_LABEL = "-- NORMAL --";
+ private static final String DEFAULT_INSERT_STATUS_LABEL = "-- INSERT --";
+ private static final String DEFAULT_VISUAL_STATUS_LABEL = "-- VISUAL --";
+ private static final String DEFAULT_VISUAL_LINE_STATUS_LABEL = "-- VISUAL LINE --";
+
+ public VimConfig {
+ if (maxUndoStates <= 0) {
+ throw new IllegalArgumentException("maxUndoStates must be positive");
+ }
+ if (normalStatusLabel == null || normalStatusLabel.isEmpty()) {
+ throw new IllegalArgumentException("normalStatusLabel must not be empty");
+ }
+ if (insertStatusLabel == null || insertStatusLabel.isEmpty()) {
+ throw new IllegalArgumentException("insertStatusLabel must not be empty");
+ }
+ if (visualStatusLabel == null || visualStatusLabel.isEmpty()) {
+ throw new IllegalArgumentException("visualStatusLabel must not be empty");
+ }
+ if (visualLineStatusLabel == null || visualLineStatusLabel.isEmpty()) {
+ throw new IllegalArgumentException("visualLineStatusLabel must not be empty");
+ }
+ }
+
+ public static VimConfig defaults() {
+ return new VimConfig(DEFAULT_MAX_UNDO_STATES, DEFAULT_CTRL_REDO_CHARACTER, DEFAULT_NORMAL_STATUS_LABEL,
+ DEFAULT_INSERT_STATUS_LABEL, DEFAULT_VISUAL_STATUS_LABEL, DEFAULT_VISUAL_LINE_STATUS_LABEL, true);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngine.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngine.java
new file mode 100644
index 0000000..dd577fb
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngine.java
@@ -0,0 +1,205 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim;
+
+import coffee.liz.abstractionengine.app.ui.editor.CursorStyle;
+import coffee.liz.abstractionengine.app.ui.editor.InputStrategy;
+import coffee.liz.abstractionengine.app.ui.editor.KeyEvent;
+import coffee.liz.abstractionengine.app.ui.editor.LineNumberMode;
+import coffee.liz.abstractionengine.app.ui.editor.EditorModifier;
+import coffee.liz.abstractionengine.app.ui.editor.RenderState;
+import coffee.liz.abstractionengine.app.ui.editor.EditorTextArea;
+import coffee.liz.abstractionengine.app.ui.editor.vim.buffer.TextAreaBuffer;
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.HandleResult;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.InsertModeHandler;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.ModeHandler;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.NormalModeHandler;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.SearchModeHandler;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.VisualLineModeHandler;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.VisualModeHandler;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.Register;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.UndoManager;
+import coffee.liz.abstractionengine.app.ui.editor.vim.navigation.TextNavigation;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.InsertState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.ModeState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.SearchState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualLineState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.status.StatusFormatter;
+import com.badlogic.gdx.Input;
+import java.util.Set;
+import java.util.function.Consumer;
+
+public class VimEngine implements InputStrategy {
+ private final VimConfig config;
+ private final KeyContext context;
+ private final StatusFormatter statusFormatter;
+ private final ModeHandler<NormalState> normalModeHandler;
+ private final ModeHandler<InsertState> insertModeHandler;
+ private final ModeHandler<VisualState> visualModeHandler;
+ private final ModeHandler<VisualLineState> visualLineModeHandler;
+ private final ModeHandler<SearchState> searchModeHandler;
+
+ private ModeState state;
+
+ public VimEngine(final EditorTextArea textArea, final Consumer<String> statusSink, final VimConfig config) {
+ this(new TextAreaBuffer(textArea), statusSink, config);
+ }
+
+ public VimEngine(final EditorTextArea textArea, final Consumer<String> statusSink) {
+ this(textArea, statusSink, VimConfig.defaults());
+ }
+
+ public VimEngine(final EditorTextArea textArea) {
+ this(textArea, null, VimConfig.defaults());
+ }
+
+ public VimEngine(final EditorBuffer buffer, final Consumer<String> statusSink, final VimConfig config) {
+ this.config = config != null ? config : VimConfig.defaults();
+ this.context = new KeyContext(buffer, statusSink, new TextNavigation(),
+ new UndoManager(this.config.maxUndoStates()), new Register(), this.config);
+ this.statusFormatter = new StatusFormatter();
+ this.normalModeHandler = new NormalModeHandler();
+ this.insertModeHandler = new InsertModeHandler();
+ this.visualModeHandler = new VisualModeHandler();
+ this.visualLineModeHandler = new VisualLineModeHandler();
+ this.searchModeHandler = new SearchModeHandler();
+ this.state = NormalState.DEFAULT;
+ context.recordUndoState();
+ publishStatus();
+ }
+
+ public VimEngine(final EditorBuffer buffer, final Consumer<String> statusSink) {
+ this(buffer, statusSink, VimConfig.defaults());
+ }
+
+ @Override
+ public boolean onKeyDown(final KeyEvent event) {
+ return handleKeyDown(event);
+ }
+
+ @Override
+ public boolean onKeyTyped(final char character) {
+ return handleKeyTyped(character);
+ }
+
+ @Override
+ public RenderState getRenderState() {
+ if (isBlockCursorMode(state)) {
+ return new RenderState(CursorStyle.BLOCK, LineNumberMode.RELATIVE);
+ }
+ return new RenderState(CursorStyle.CARET, LineNumberMode.ABSOLUTE);
+ }
+
+ public String getStatusText() {
+ return context.getStatusText();
+ }
+
+ public boolean handleKeyDown(final int keycode) {
+ return handleKeyDown(new KeyEvent(keycode, Set.of()));
+ }
+
+ public boolean handleKeyDown(final int keycode, final boolean ctrlDown) {
+ final Set<EditorModifier> modifiers = ctrlDown ? Set.of(EditorModifier.CTRL) : Set.of();
+ return handleKeyDown(new KeyEvent(keycode, modifiers));
+ }
+
+ public boolean handleKeyDown(final KeyEvent event) {
+ context.setCurrentKeyEvent(event);
+ if (isInsertState(state)) {
+ final boolean consumed = applyHandleResult(insertModeHandler.onKeyDown((InsertState) state, context));
+ return consumed;
+ }
+
+ if (isRedoKeyDown(event)) {
+ state = context.redoToNormalState();
+ context.suppressNextRedoTyped();
+ publishStatus();
+ return true;
+ }
+
+ final HandleResult result = dispatchKeyDown(state);
+ if (applyHandleResult(result)) {
+ return true;
+ }
+
+ return !isInsertState(state);
+ }
+
+ public boolean handleKeyTyped(final char character) {
+ if (character == config.ctrlRedoCharacter()) {
+ if (context.consumeRedoTypedSuppression()) {
+ return true;
+ }
+ state = context.redoToNormalState();
+ publishStatus();
+ return true;
+ }
+
+ if (context.consumeSuppressedTypedCommand()) {
+ return true;
+ }
+ context.clearRedoTypedSuppression();
+
+ if (isInsertState(state)) {
+ return false;
+ }
+
+ final HandleResult result = dispatchKeyTyped(state, character);
+ if (applyHandleResult(result)) {
+ return true;
+ }
+
+ return !isInsertState(state);
+ }
+
+ private HandleResult dispatchKeyDown(final ModeState modeState) {
+ return switch (modeState) {
+ case NormalState normalState -> normalModeHandler.onKeyDown(normalState, context);
+ case InsertState insertState -> insertModeHandler.onKeyDown(insertState, context);
+ case VisualState visualState -> visualModeHandler.onKeyDown(visualState, context);
+ case VisualLineState visualLineState -> visualLineModeHandler.onKeyDown(visualLineState, context);
+ case SearchState searchState -> searchModeHandler.onKeyDown(searchState, context);
+ };
+ }
+
+ private HandleResult dispatchKeyTyped(final ModeState modeState, final char character) {
+ return switch (modeState) {
+ case NormalState normalState -> normalModeHandler.onKeyTyped(normalState, character, context);
+ case InsertState insertState -> insertModeHandler.onKeyTyped(insertState, character, context);
+ case VisualState visualState -> visualModeHandler.onKeyTyped(visualState, character, context);
+ case VisualLineState visualLineState ->
+ visualLineModeHandler.onKeyTyped(visualLineState, character, context);
+ case SearchState searchState -> searchModeHandler.onKeyTyped(searchState, character, context);
+ };
+ }
+
+ private boolean applyHandleResult(final HandleResult result) {
+ if (result.suppressTyped()) {
+ context.incrementSuppressedTypedCommands();
+ }
+ if (result.nextState() != null) {
+ state = result.nextState();
+ }
+ publishStatus();
+ return result.consumed();
+ }
+
+ private boolean isRedoKeyDown(final KeyEvent event) {
+ final boolean ctrlDown = event.hasModifier(EditorModifier.CTRL) || event.hasModifier(EditorModifier.META);
+ return ctrlDown && event.keycode() == Input.Keys.R;
+ }
+
+ private boolean isInsertState(final ModeState modeState) {
+ return modeState instanceof InsertState;
+ }
+
+ private boolean isBlockCursorMode(final ModeState modeState) {
+ return modeState instanceof NormalState || modeState instanceof VisualState
+ || modeState instanceof VisualLineState;
+ }
+
+ private void publishStatus() {
+ context.publishStatus(statusFormatter.format(state, config));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/buffer/TextAreaBuffer.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/buffer/TextAreaBuffer.java
new file mode 100644
index 0000000..36fe9d3
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/buffer/TextAreaBuffer.java
@@ -0,0 +1,64 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.buffer;
+
+import coffee.liz.abstractionengine.app.ui.editor.EditorTextArea;
+import coffee.liz.abstractionengine.app.ui.editor.vim.EditorBuffer;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class TextAreaBuffer implements EditorBuffer {
+ private final EditorTextArea textArea;
+
+ @Override
+ public String getText() {
+ return textArea.getText();
+ }
+
+ @Override
+ public void setText(final String text) {
+ if (text.equals(textArea.getText())) {
+ return;
+ }
+ textArea.setText(text);
+ textArea.syncCursorState();
+ }
+
+ @Override
+ public int getCursorPosition() {
+ return textArea.getCursorPosition();
+ }
+
+ @Override
+ public void setCursorPosition(final int cursorPosition) {
+ final int clampedCursor = Math.max(0, Math.min(cursorPosition, textArea.getText().length()));
+ if (clampedCursor == textArea.getCursorPosition()) {
+ return;
+ }
+ textArea.setCursorPosition(clampedCursor);
+ textArea.syncCursorState();
+ }
+
+ @Override
+ public int getCursorLine() {
+ return textArea.getCursorLine();
+ }
+
+ @Override
+ public int getLines() {
+ return textArea.getLines();
+ }
+
+ @Override
+ public void moveCursorLine(final int line) {
+ textArea.moveCursorLine(line);
+ }
+
+ @Override
+ public void setSelectionEndpoints(final int selectionStart, final int cursorPosition) {
+ textArea.setSelectionEndpoints(selectionStart, cursorPosition);
+ }
+
+ @Override
+ public void clearSelection() {
+ textArea.clearSelection();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/context/KeyContext.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/context/KeyContext.java
new file mode 100644
index 0000000..a15232d
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/context/KeyContext.java
@@ -0,0 +1,706 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.context;
+
+import coffee.liz.abstractionengine.app.ui.editor.KeyEvent;
+import coffee.liz.abstractionengine.app.ui.editor.EditorModifier;
+import coffee.liz.abstractionengine.app.ui.editor.vim.EditorBuffer;
+import coffee.liz.abstractionengine.app.ui.editor.vim.VimConfig;
+import coffee.liz.abstractionengine.app.ui.editor.vim.handler.HandleResult;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.Register;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.RegisterType;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.UndoManager;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.UndoState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.navigation.TextNavigation;
+import coffee.liz.abstractionengine.app.ui.editor.vim.search.SearchDirection;
+import coffee.liz.abstractionengine.app.ui.editor.vim.search.SearchIterator;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.InsertState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.ModeState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.PendingOperator;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualLineState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualState;
+import com.badlogic.gdx.Input;
+import java.util.OptionalInt;
+import java.util.Set;
+import java.util.function.Consumer;
+
+public class KeyContext {
+ private final EditorBuffer editorBuffer;
+ private final Consumer<String> statusUpdater;
+ private final TextNavigation navigation;
+ private final UndoManager undoManager;
+ private final Register register;
+ private final VimConfig config;
+
+ private String statusText;
+ private String lastSearchQuery;
+ private SearchDirection lastSearchDirection;
+ private SearchIterator searchIterator;
+ private String searchIteratorQuery;
+ private String searchIteratorText;
+ private int suppressedTypedCommands;
+ private boolean suppressNextRedoTyped;
+ private KeyEvent currentKeyEvent;
+
+ public KeyContext(final EditorBuffer editorBuffer, final Consumer<String> statusUpdater,
+ final TextNavigation navigation, final UndoManager undoManager, final Register register,
+ final VimConfig config) {
+ this.editorBuffer = editorBuffer;
+ this.statusUpdater = statusUpdater;
+ this.navigation = navigation;
+ this.undoManager = undoManager;
+ this.register = register;
+ this.config = config;
+ this.statusText = "";
+ this.lastSearchQuery = "";
+ this.lastSearchDirection = SearchDirection.FORWARD;
+ this.searchIterator = null;
+ this.searchIteratorQuery = "";
+ this.searchIteratorText = "";
+ this.suppressedTypedCommands = 0;
+ this.suppressNextRedoTyped = false;
+ this.currentKeyEvent = new KeyEvent(Input.Keys.UNKNOWN, Set.of());
+ }
+
+ public EditorBuffer editorBuffer() {
+ return editorBuffer;
+ }
+
+ public TextNavigation navigation() {
+ return navigation;
+ }
+
+ public VimConfig config() {
+ return config;
+ }
+
+ public void setCurrentKeyEvent(final KeyEvent currentKeyEvent) {
+ this.currentKeyEvent = currentKeyEvent;
+ }
+
+ public KeyEvent currentKeyEvent() {
+ return currentKeyEvent;
+ }
+
+ public String getStatusText() {
+ return statusText;
+ }
+
+ public void publishStatus(final String status) {
+ if (!status.equals(statusText)) {
+ statusText = status;
+ if (statusUpdater != null) {
+ statusUpdater.accept(statusText);
+ }
+ }
+ }
+
+ public String getLastSearchQuery() {
+ return lastSearchQuery;
+ }
+
+ public void setLastSearchQuery(final String lastSearchQuery) {
+ this.lastSearchQuery = lastSearchQuery == null ? "" : lastSearchQuery;
+ }
+
+ public void incrementSuppressedTypedCommands() {
+ suppressedTypedCommands++;
+ }
+
+ public boolean consumeSuppressedTypedCommand() {
+ if (suppressedTypedCommands <= 0) {
+ return false;
+ }
+ suppressedTypedCommands--;
+ return true;
+ }
+
+ public void suppressNextRedoTyped() {
+ suppressNextRedoTyped = true;
+ }
+
+ public boolean consumeRedoTypedSuppression() {
+ if (!suppressNextRedoTyped) {
+ return false;
+ }
+ suppressNextRedoTyped = false;
+ return true;
+ }
+
+ public void clearRedoTypedSuppression() {
+ suppressNextRedoTyped = false;
+ }
+
+ public Character toCommandCharacter() {
+ final int keycode = currentKeyEvent.keycode();
+ final boolean shiftDown = currentKeyEvent.hasModifier(EditorModifier.SHIFT);
+
+ if (keycode == Input.Keys.SLASH) {
+ return shiftDown ? '?' : '/';
+ }
+
+ if (keycode == Input.Keys.NUM_4 && shiftDown) {
+ return '$';
+ }
+
+ final String keyName = Input.Keys.toString(keycode);
+ if (keyName == null || keyName.length() != 1) {
+ return null;
+ }
+
+ final char key = keyName.charAt(0);
+ if (Character.isLetter(key)) {
+ return shiftDown ? Character.toUpperCase(key) : Character.toLowerCase(key);
+ }
+
+ if (Character.isDigit(key) && !shiftDown) {
+ return key;
+ }
+
+ return null;
+ }
+
+ public int readCount(final int count) {
+ return count > 0 ? count : 1;
+ }
+
+ public int resetPreferredColumn(final char character, final int preferredColumn) {
+ if (character == 'j' || character == 'k') {
+ return preferredColumn;
+ }
+ return -1;
+ }
+
+ public void moveLeft() {
+ final int cursor = editorBuffer.getCursorPosition();
+ final int lineStart = navigation.findLineStart(editorBuffer.getText(), cursor);
+ if (cursor > lineStart) {
+ editorBuffer.setCursorPosition(cursor - 1);
+ }
+ }
+
+ public void moveRightNormal() {
+ final String text = editorBuffer.getText();
+ final int cursor = editorBuffer.getCursorPosition();
+ final int lineEnd = navigation.findLineEnd(text, cursor);
+ if (cursor < lineEnd) {
+ editorBuffer.setCursorPosition(cursor + 1);
+ }
+ }
+
+ public void moveRightForAppend() {
+ final String text = editorBuffer.getText();
+ final int cursor = editorBuffer.getCursorPosition();
+ if (cursor < text.length() && text.charAt(cursor) != '\n') {
+ editorBuffer.setCursorPosition(cursor + 1);
+ }
+ }
+
+ public int moveVertical(final int delta, final int repeats, final int preferredColumn,
+ final boolean clampInNormalMode) {
+ int preferred = preferredColumn;
+ for (int i = 0; i < repeats; i++) {
+ preferred = moveVerticalOnce(delta, preferred, clampInNormalMode);
+ }
+ return preferred;
+ }
+
+ private int moveVerticalOnce(final int delta, final int preferredColumn, final boolean clampInNormalMode) {
+ if (delta == 0) {
+ return preferredColumn;
+ }
+
+ final String text = editorBuffer.getText();
+ if (text.isEmpty()) {
+ return preferredColumn;
+ }
+
+ final int cursor = editorBuffer.getCursorPosition();
+ final int currentLineStart = navigation.findLineStart(text, cursor);
+ final int resolvedPreferred = preferredColumn < 0 ? Math.max(0, cursor - currentLineStart) : preferredColumn;
+
+ int targetStart = currentLineStart;
+ if (delta > 0) {
+ targetStart = navigation.findNextLineStart(text, targetStart);
+ if (targetStart < 0 || targetStart > text.length()) {
+ return resolvedPreferred;
+ }
+ } else {
+ targetStart = navigation.findPreviousLineStart(text, targetStart);
+ if (targetStart < 0) {
+ return resolvedPreferred;
+ }
+ }
+
+ final int targetEndExclusive = navigation.findLineEndExclusiveByStart(text, targetStart);
+ final int targetLineLength = Math.max(0, targetEndExclusive - targetStart);
+ final int maxColumn = targetLineLength > 0 ? targetLineLength - 1 : 0;
+ final int targetColumn = Math.min(resolvedPreferred, maxColumn);
+ editorBuffer.setCursorPosition(targetStart + targetColumn);
+ if (clampInNormalMode) {
+ clampCursorInNormalMode();
+ }
+ return resolvedPreferred;
+ }
+
+ public void moveWordForward() {
+ final String text = editorBuffer.getText();
+ int cursor = editorBuffer.getCursorPosition();
+ final int length = text.length();
+
+ if (cursor >= length) {
+ return;
+ }
+
+ final int originalCursor = cursor;
+ final char current = text.charAt(cursor);
+
+ if (navigation.isWordCharacter(current)) {
+ while (cursor < length && navigation.isWordCharacter(text.charAt(cursor))) {
+ cursor++;
+ }
+ while (cursor < length && navigation.isInlineWhitespace(text.charAt(cursor))) {
+ cursor++;
+ }
+ } else if (navigation.isPunctuationWord(current)) {
+ while (cursor < length && navigation.isPunctuationWord(text.charAt(cursor))) {
+ cursor++;
+ }
+ while (cursor < length && navigation.isInlineWhitespace(text.charAt(cursor))) {
+ cursor++;
+ }
+ if (cursor >= length) {
+ cursor = originalCursor;
+ }
+ } else if (navigation.isInlineWhitespace(current)) {
+ while (cursor < length && navigation.isInlineWhitespace(text.charAt(cursor))) {
+ cursor++;
+ }
+ } else if (current == '\n' || current == '\r') {
+ if (cursor < length) {
+ cursor++;
+ }
+ while (cursor < length && navigation.isInlineWhitespace(text.charAt(cursor))) {
+ cursor++;
+ }
+ }
+
+ editorBuffer.setCursorPosition(Math.min(cursor, length));
+ }
+
+ public void moveWordBackward() {
+ final String text = editorBuffer.getText();
+ int cursor = editorBuffer.getCursorPosition();
+
+ if (cursor <= 0) {
+ return;
+ }
+
+ cursor--;
+ while (cursor > 0 && Character.isWhitespace(text.charAt(cursor))) {
+ cursor--;
+ }
+
+ final char current = text.charAt(cursor);
+ if (navigation.isWordCharacter(current)) {
+ while (cursor > 0 && navigation.isWordCharacter(text.charAt(cursor - 1))) {
+ cursor--;
+ }
+ } else {
+ while (cursor > 0) {
+ final char previous = text.charAt(cursor - 1);
+ if (Character.isWhitespace(previous) || navigation.isWordCharacter(previous)) {
+ break;
+ }
+ cursor--;
+ }
+ }
+
+ editorBuffer.setCursorPosition(cursor);
+ }
+
+ public void moveLineStart() {
+ final int cursor = editorBuffer.getCursorPosition();
+ editorBuffer.setCursorPosition(navigation.findLineStart(editorBuffer.getText(), cursor));
+ }
+
+ public void moveLineEnd() {
+ final int cursor = editorBuffer.getCursorPosition();
+ editorBuffer.setCursorPosition(navigation.findLineEnd(editorBuffer.getText(), cursor));
+ }
+
+ public void moveDocumentEnd() {
+ final String text = editorBuffer.getText();
+ if (!text.isEmpty()) {
+ editorBuffer.setCursorPosition(navigation.lastNavigablePosition(text));
+ }
+ }
+
+ public void moveDocumentStart() {
+ editorBuffer.setCursorPosition(0);
+ clampCursorInNormalMode();
+ }
+
+ public void openLineBelow() {
+ final String text = editorBuffer.getText();
+ final int insertAt = navigation.findBelowLineInsertionIndex(text, editorBuffer.getCursorPosition());
+ editorBuffer.setText(text.substring(0, insertAt) + "\n" + text.substring(insertAt));
+ editorBuffer.setCursorPosition(Math.min(insertAt, editorBuffer.getText().length()));
+ invalidateSearchIterator();
+ recordUndoState();
+ }
+
+ public void openLineAbove() {
+ final String text = editorBuffer.getText();
+ final int lineStart = navigation.findLineStart(text, editorBuffer.getCursorPosition());
+ editorBuffer.setText(text.substring(0, lineStart) + "\n" + text.substring(lineStart));
+ editorBuffer.setCursorPosition(lineStart);
+ invalidateSearchIterator();
+ recordUndoState();
+ }
+
+ public void deleteCharUnderCursor() {
+ final String text = editorBuffer.getText();
+ final int cursor = editorBuffer.getCursorPosition();
+ if (cursor >= text.length() || text.charAt(cursor) == '\n') {
+ return;
+ }
+ register.setText(String.valueOf(text.charAt(cursor)));
+ register.setType(RegisterType.CHARACTER);
+ editorBuffer.setText(text.substring(0, cursor) + text.substring(cursor + 1));
+ editorBuffer.setCursorPosition(Math.min(cursor, editorBuffer.getText().length()));
+ invalidateSearchIterator();
+ }
+
+ public void deleteLines(final int repeats) {
+ final String text = editorBuffer.getText();
+ final int start = navigation.findLineStart(text, editorBuffer.getCursorPosition());
+ int end = start;
+ for (int i = 0; i < repeats; i++) {
+ end = navigation.findNextLineStart(text, end);
+ if (end == -1) {
+ end = text.length();
+ break;
+ }
+ }
+ if (start >= end) {
+ return;
+ }
+ register.setText(normalizeLineRegister(text.substring(start, end)));
+ register.setType(RegisterType.LINE);
+ editorBuffer.setText(text.substring(0, start) + text.substring(end));
+ editorBuffer.setCursorPosition(Math.min(start, editorBuffer.getText().length()));
+ clampCursorInNormalMode();
+ invalidateSearchIterator();
+ }
+
+ public void yankLines(final int repeats) {
+ final String text = editorBuffer.getText();
+ final int start = navigation.findLineStart(text, editorBuffer.getCursorPosition());
+ int end = start;
+ for (int i = 0; i < repeats; i++) {
+ end = navigation.findNextLineStart(text, end);
+ if (end == -1) {
+ end = text.length();
+ break;
+ }
+ }
+ if (start < end) {
+ register.setText(normalizeLineRegister(text.substring(start, end)));
+ register.setType(RegisterType.LINE);
+ }
+ }
+
+ public void changeLines(final int repeats) {
+ deleteLines(repeats);
+ }
+
+ public void pasteAfter() {
+ if (register.getText().isEmpty()) {
+ return;
+ }
+ final String text = editorBuffer.getText();
+ final int cursor = editorBuffer.getCursorPosition();
+ if (register.getType() == RegisterType.LINE) {
+ final int insertAt = navigation.findBelowLineInsertionIndex(text, cursor);
+ String inserted = register.getText();
+ if (insertAt == text.length() && insertAt > 0 && text.charAt(insertAt - 1) != '\n') {
+ inserted = "\n" + inserted;
+ }
+ editorBuffer.setText(text.substring(0, insertAt) + inserted + text.substring(insertAt));
+ editorBuffer.setCursorPosition(Math.min(insertAt, editorBuffer.getText().length()));
+ invalidateSearchIterator();
+ return;
+ }
+
+ final int insertAt = Math.min(cursor + 1, text.length());
+ editorBuffer.setText(text.substring(0, insertAt) + register.getText() + text.substring(insertAt));
+ editorBuffer.setCursorPosition(insertAt + register.getText().length() - 1);
+ invalidateSearchIterator();
+ }
+
+ public void pasteBefore() {
+ if (register.getText().isEmpty()) {
+ return;
+ }
+ final String text = editorBuffer.getText();
+ final int cursor = editorBuffer.getCursorPosition();
+ if (register.getType() == RegisterType.LINE) {
+ final int insertAt = navigation.findLineStart(text, cursor);
+ editorBuffer.setText(text.substring(0, insertAt) + register.getText() + text.substring(insertAt));
+ editorBuffer.setCursorPosition(insertAt);
+ invalidateSearchIterator();
+ return;
+ }
+
+ final int insertAt = cursor;
+ editorBuffer.setText(text.substring(0, insertAt) + register.getText() + text.substring(insertAt));
+ editorBuffer.setCursorPosition(insertAt + register.getText().length() - 1);
+ invalidateSearchIterator();
+ }
+
+ public void updateVisualSelection(final int anchor) {
+ final int cursor = editorBuffer.getCursorPosition();
+ if (cursor != anchor) {
+ int selectionStart = anchor;
+ if (cursor < anchor && anchor < editorBuffer.getText().length()) {
+ selectionStart = anchor + 1;
+ }
+ editorBuffer.setSelectionEndpoints(selectionStart, cursor);
+ } else {
+ editorBuffer.clearSelection();
+ }
+ }
+
+ public void updateVisualLineSelection(final int anchorLine, final int cursorLine) {
+ final String text = editorBuffer.getText();
+ final int startLine = Math.min(anchorLine, cursorLine);
+ final int endLine = Math.max(anchorLine, cursorLine);
+ final int start = navigation.findLineStartByLine(text, startLine);
+ final int end = navigation.findLineSelectionEndByLine(text, endLine);
+ if (start < end) {
+ final int selectionStart = cursorLine > anchorLine ? start : end;
+ final int cursorPosition = cursorLine > anchorLine
+ ? resolveVisualLineCursorPosition(text, start, end)
+ : start;
+ editorBuffer.setSelectionEndpoints(selectionStart, cursorPosition);
+ } else {
+ editorBuffer.clearSelection();
+ }
+ }
+
+ public int adjustVisualLineCursor(final int cursorLine, final int delta) {
+ final int maxLine = maxSelectableLineIndex();
+ return Math.max(0, Math.min(maxLine, cursorLine + delta));
+ }
+
+ public int maxSelectableLineIndex() {
+ int totalLines = editorBuffer.getLines();
+ final String text = editorBuffer.getText();
+ if (totalLines > 1 && text.endsWith("\n")) {
+ totalLines--;
+ }
+ return Math.max(0, totalLines - 1);
+ }
+
+ private int resolveVisualLineCursorPosition(final String text, final int lineStart, final int selectionEnd) {
+ if (!text.isEmpty() && selectionEnd == text.length() && text.charAt(text.length() - 1) == '\n') {
+ return Math.max(lineStart, selectionEnd - 1);
+ }
+ return selectionEnd;
+ }
+
+ public HandleResult applyVisualOperator(final VisualState state, final PendingOperator operator) {
+ final int start = Math.min(state.anchor(), editorBuffer.getCursorPosition());
+ final int max = Math.max(state.anchor(), editorBuffer.getCursorPosition());
+ final int end = Math.min(editorBuffer.getText().length(), max + 1);
+ if (start >= end) {
+ editorBuffer.clearSelection();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ final String text = editorBuffer.getText();
+ register.setText(text.substring(start, end));
+ register.setType(RegisterType.CHARACTER);
+
+ if (operator == PendingOperator.YANK) {
+ editorBuffer.clearSelection();
+ editorBuffer.setCursorPosition(start);
+ clampCursorInNormalMode();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ editorBuffer.setText(text.substring(0, start) + text.substring(end));
+ editorBuffer.setCursorPosition(Math.min(start, editorBuffer.getText().length()));
+ editorBuffer.clearSelection();
+ recordUndoState();
+ invalidateSearchIterator();
+
+ if (operator == PendingOperator.CHANGE) {
+ return HandleResult.handled(new InsertState(editorBuffer.getText(), editorBuffer.getCursorPosition()));
+ }
+ clampCursorInNormalMode();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ public HandleResult applyVisualLineOperator(final VisualLineState state, final PendingOperator operator) {
+ final String text = editorBuffer.getText();
+ final int startLine = Math.min(state.anchorLine(), state.cursorLine());
+ final int endLine = Math.max(state.anchorLine(), state.cursorLine());
+ final int start = navigation.findLineStartByLine(text, startLine);
+ final int end = navigation.findLineSelectionEndByLine(text, endLine);
+ if (start >= end) {
+ editorBuffer.clearSelection();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ register.setText(normalizeLineRegister(text.substring(start, end)));
+ register.setType(RegisterType.LINE);
+
+ if (operator == PendingOperator.YANK) {
+ editorBuffer.clearSelection();
+ editorBuffer.setCursorPosition(start);
+ clampCursorInNormalMode();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ editorBuffer.setText(text.substring(0, start) + text.substring(end));
+ editorBuffer.setCursorPosition(Math.min(start, editorBuffer.getText().length()));
+ editorBuffer.clearSelection();
+ recordUndoState();
+ invalidateSearchIterator();
+
+ if (operator == PendingOperator.CHANGE) {
+ return HandleResult.handled(new InsertState(editorBuffer.getText(), editorBuffer.getCursorPosition()));
+ }
+ clampCursorInNormalMode();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ public boolean findNext(final String query, final SearchDirection direction) {
+ if (query == null || query.isEmpty()) {
+ return false;
+ }
+ final String text = editorBuffer.getText();
+ if (text.isEmpty()) {
+ return false;
+ }
+
+ if (searchIterator == null || !query.equals(searchIteratorQuery) || direction != lastSearchDirection
+ || !text.equals(searchIteratorText)) {
+ searchIterator = new SearchIterator(text, query, editorBuffer.getCursorPosition(), direction,
+ config.wrapSearch());
+ searchIteratorQuery = query;
+ searchIteratorText = text;
+ lastSearchDirection = direction;
+ }
+
+ final OptionalInt found = searchIterator.nextMatch();
+ if (found.isEmpty()) {
+ return false;
+ }
+
+ editorBuffer.setCursorPosition(found.getAsInt());
+ clampCursorInNormalMode();
+ return true;
+ }
+
+ public void invalidateSearchIterator() {
+ searchIterator = null;
+ searchIteratorQuery = "";
+ searchIteratorText = "";
+ }
+
+ public void recordUndoState() {
+ undoManager.record(editorBuffer.getText(), editorBuffer.getCursorPosition());
+ }
+
+ public ModeState undoToNormalState() {
+ final UndoState previous = undoManager.undo();
+ if (previous == null) {
+ return NormalState.DEFAULT;
+ }
+ applyState(previous);
+ return NormalState.DEFAULT;
+ }
+
+ public ModeState redoToNormalState() {
+ final UndoState target = undoManager.redo();
+ if (target == null) {
+ return NormalState.DEFAULT;
+ }
+ applyState(target);
+ return NormalState.DEFAULT;
+ }
+
+ public void maybeRecordInsertEdit(final InsertState state) {
+ final String currentText = editorBuffer.getText();
+ final int currentCursor = editorBuffer.getCursorPosition();
+ if (!currentText.equals(state.insertStartText()) || currentCursor != state.insertStartCursor()) {
+ recordUndoState();
+ }
+ }
+
+ private void applyState(final UndoState state) {
+ editorBuffer.setText(state.getText());
+ editorBuffer.setCursorPosition(Math.min(state.getCursor(), state.getText().length()));
+ editorBuffer.clearSelection();
+ clampCursorInNormalMode();
+ invalidateSearchIterator();
+ }
+
+ public void clampCursorInNormalMode() {
+ final String text = editorBuffer.getText();
+ if (text.isEmpty()) {
+ editorBuffer.setCursorPosition(0);
+ return;
+ }
+ final int maxCursor = navigation.maxCursorPositionInNormalMode(text);
+ final int cursor = Math.min(editorBuffer.getCursorPosition(), maxCursor);
+ final int lineStart = navigation.findLineStart(text, cursor);
+ if (cursor < text.length() && text.charAt(cursor) == '\n' && cursor > lineStart) {
+ editorBuffer.setCursorPosition(cursor - 1);
+ } else {
+ editorBuffer.setCursorPosition(cursor);
+ }
+ }
+
+ public int getCursorPhysicalLine() {
+ return editorBuffer.getCursorLine();
+ }
+
+ public String text() {
+ return editorBuffer.getText();
+ }
+
+ public int cursor() {
+ return editorBuffer.getCursorPosition();
+ }
+
+ public void setCursor(final int position) {
+ editorBuffer.setCursorPosition(position);
+ }
+
+ public void setText(final String text) {
+ editorBuffer.setText(text);
+ invalidateSearchIterator();
+ }
+
+ public void clearSelection() {
+ editorBuffer.clearSelection();
+ }
+
+ public void setSelectionEndpoints(final int selectionStart, final int cursorPosition) {
+ editorBuffer.setSelectionEndpoints(selectionStart, cursorPosition);
+ }
+
+ public Register register() {
+ return register;
+ }
+
+ private String normalizeLineRegister(final String lineText) {
+ if (lineText.isEmpty() || lineText.charAt(lineText.length() - 1) == '\n') {
+ return lineText;
+ }
+ return lineText + "\n";
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/CommandAction.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/CommandAction.java
new file mode 100644
index 0000000..7a1b85c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/CommandAction.java
@@ -0,0 +1,8 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.ModeState;
+
+public interface CommandAction<S extends ModeState> {
+ HandleResult execute(S state, KeyContext context);
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/HandleResult.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/HandleResult.java
new file mode 100644
index 0000000..9a4e2e4
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/HandleResult.java
@@ -0,0 +1,32 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.ModeState;
+
+public record HandleResult(boolean consumed, boolean suppressTyped, ModeState nextState) {
+ public static HandleResult notHandled() {
+ return new HandleResult(false, false, null);
+ }
+
+ public static HandleResult handled() {
+ return new HandleResult(true, false, null);
+ }
+
+ public static HandleResult handled(final ModeState nextState) {
+ return new HandleResult(true, false, nextState);
+ }
+
+ public static HandleResult handledAndSuppressTyped() {
+ return new HandleResult(true, true, null);
+ }
+
+ public static HandleResult handledAndSuppressTyped(final ModeState nextState) {
+ return new HandleResult(true, true, nextState);
+ }
+
+ public HandleResult withSuppressTyped() {
+ if (!consumed) {
+ return this;
+ }
+ return new HandleResult(true, true, nextState);
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/InsertModeHandler.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/InsertModeHandler.java
new file mode 100644
index 0000000..176664c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/InsertModeHandler.java
@@ -0,0 +1,24 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.InsertState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import com.badlogic.gdx.Input;
+
+public class InsertModeHandler implements ModeHandler<InsertState> {
+ @Override
+ public HandleResult onKeyDown(final InsertState state, final KeyContext context) {
+ if (context.currentKeyEvent().keycode() == Input.Keys.ESCAPE) {
+ context.maybeRecordInsertEdit(state);
+ context.clampCursorInNormalMode();
+ context.clearSelection();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+ return HandleResult.notHandled();
+ }
+
+ @Override
+ public HandleResult onKeyTyped(final InsertState state, final char character, final KeyContext context) {
+ return HandleResult.notHandled();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/ModeHandler.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/ModeHandler.java
new file mode 100644
index 0000000..ed04ce2
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/ModeHandler.java
@@ -0,0 +1,10 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.ModeState;
+
+public interface ModeHandler<T extends ModeState> {
+ HandleResult onKeyDown(T state, KeyContext context);
+
+ HandleResult onKeyTyped(T state, char character, KeyContext context);
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/MotionAction.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/MotionAction.java
new file mode 100644
index 0000000..e6332dd
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/MotionAction.java
@@ -0,0 +1,7 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+
+public interface MotionAction {
+ int execute(KeyContext context, int repeats, int preferredColumn, boolean clampInNormalMode);
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/NormalModeHandler.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/NormalModeHandler.java
new file mode 100644
index 0000000..65e6f71
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/NormalModeHandler.java
@@ -0,0 +1,326 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.model.RegisterType;
+import coffee.liz.abstractionengine.app.ui.editor.vim.search.SearchDirection;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.InsertState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.PendingOperator;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.SearchState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualLineState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualState;
+import com.badlogic.gdx.Input;
+import java.util.HashMap;
+import java.util.Map;
+
+public class NormalModeHandler implements ModeHandler<NormalState> {
+ private final Map<Character, CommandAction<NormalState>> commandActions = createCommandActions();
+ private final Map<Character, MotionAction> motionActions = createMotionActions();
+
+ @Override
+ public HandleResult onKeyDown(final NormalState state, final KeyContext context) {
+ if (context.currentKeyEvent().keycode() == Input.Keys.ESCAPE) {
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ final Character commandCharacter = context.toCommandCharacter();
+ if (commandCharacter != null) {
+ return onKeyTyped(state, commandCharacter.charValue(), context).withSuppressTyped();
+ }
+
+ return HandleResult.handled();
+ }
+
+ @Override
+ public HandleResult onKeyTyped(final NormalState state, final char character, final KeyContext context) {
+ if (character >= '1' && character <= '9') {
+ return HandleResult.handled(new NormalState(state.count() * 10 + (character - '0'), state.pendingOperator(),
+ state.preferredColumn()));
+ }
+
+ if (character == '0' && state.count() > 0) {
+ return HandleResult
+ .handled(new NormalState(state.count() * 10, state.pendingOperator(), state.preferredColumn()));
+ }
+
+ if (state.pendingOperator() != PendingOperator.NONE) {
+ return handlePendingOperator(state, character, context);
+ }
+
+ final NormalState normalizedState = new NormalState(state.count(), PendingOperator.NONE,
+ context.resetPreferredColumn(character, state.preferredColumn()));
+ final CommandAction<NormalState> action = commandActions.get(character);
+ if (action == null) {
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, normalizedState.preferredColumn()));
+ }
+ return action.execute(normalizedState, context);
+ }
+
+ private HandleResult handlePendingOperator(final NormalState state, final char character,
+ final KeyContext context) {
+ if (state.pendingOperator() == PendingOperator.G_PREFIX) {
+ if (character == 'g') {
+ context.moveDocumentStart();
+ }
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ final PendingOperator operator = state.pendingOperator();
+ final int repeats = context.readCount(state.count());
+ if (character == 'd' && operator == PendingOperator.DELETE) {
+ context.deleteLines(repeats);
+ context.recordUndoState();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+ if (character == 'y' && operator == PendingOperator.YANK) {
+ context.yankLines(repeats);
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+ if (character == 'c' && operator == PendingOperator.CHANGE) {
+ context.changeLines(repeats);
+ context.recordUndoState();
+ return HandleResult.handled(new InsertState(context.text(), context.cursor()));
+ }
+
+ final int start = context.cursor();
+ final int updatedPreferred = applyMotion(character, repeats, state.preferredColumn(), context, true);
+ int end = context.cursor();
+ if (character == '$') {
+ end = Math.min(context.text().length(), end + 1);
+ }
+
+ final int rangeStart = Math.min(start, end);
+ final int rangeEnd = Math.max(start, end);
+ if (rangeStart == rangeEnd) {
+ if (character == 'l') {
+ final String text = context.text();
+ if (start < text.length() && text.charAt(start) != '\n') {
+ context.register().setText(text.substring(start, start + 1));
+ context.register().setType(RegisterType.CHARACTER);
+ if (operator == PendingOperator.YANK) {
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, updatedPreferred));
+ }
+
+ context.setText(text.substring(0, start) + text.substring(start + 1));
+ context.setCursor(Math.min(start, context.text().length()));
+ context.clampCursorInNormalMode();
+ context.recordUndoState();
+ if (operator == PendingOperator.CHANGE) {
+ return HandleResult.handled(new InsertState(context.text(), context.cursor()));
+ }
+ }
+ }
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, updatedPreferred));
+ }
+
+ if (operator == PendingOperator.YANK) {
+ context.register().setText(context.text().substring(rangeStart, rangeEnd));
+ context.register().setType(RegisterType.CHARACTER);
+ context.setCursor(start);
+ context.clampCursorInNormalMode();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, updatedPreferred));
+ }
+
+ final String text = context.text();
+ context.register().setText(text.substring(rangeStart, rangeEnd));
+ context.register().setType(RegisterType.CHARACTER);
+ context.setText(text.substring(0, rangeStart) + text.substring(rangeEnd));
+ context.setCursor(Math.min(rangeStart, context.text().length()));
+ context.clampCursorInNormalMode();
+ context.recordUndoState();
+
+ if (operator == PendingOperator.CHANGE) {
+ return HandleResult.handled(new InsertState(context.text(), context.cursor()));
+ }
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, updatedPreferred));
+ }
+
+ private int applyMotion(final char motion, final int repeats, final int preferredColumn, final KeyContext context,
+ final boolean clampInNormalMode) {
+ final MotionAction action = motionActions.get(motion);
+ if (action == null) {
+ return preferredColumn;
+ }
+ return action.execute(context, repeats, preferredColumn, clampInNormalMode);
+ }
+
+ private Map<Character, CommandAction<NormalState>> createCommandActions() {
+ final Map<Character, CommandAction<NormalState>> map = new HashMap<>();
+ map.put('h', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.moveLeft();
+ }
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('j', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ final int preferred = context.moveVertical(1, repeats, state.preferredColumn(), true);
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, preferred));
+ });
+ map.put('k', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ final int preferred = context.moveVertical(-1, repeats, state.preferredColumn(), true);
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, preferred));
+ });
+ map.put('l', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.moveRightNormal();
+ }
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('w', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.moveWordForward();
+ }
+ context.clampCursorInNormalMode();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('b', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.moveWordBackward();
+ }
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('0', (state, context) -> {
+ context.moveLineStart();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('$', (state, context) -> {
+ context.moveLineEnd();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('G', (state, context) -> {
+ context.moveDocumentEnd();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('g', (state, context) -> HandleResult
+ .handled(new NormalState(0, PendingOperator.G_PREFIX, state.preferredColumn())));
+ map.put('i', (state, context) -> HandleResult.handled(new InsertState(context.text(), context.cursor())));
+ map.put('a', (state, context) -> {
+ context.moveRightForAppend();
+ return HandleResult.handled(new InsertState(context.text(), context.cursor()));
+ });
+ map.put('o', (state, context) -> {
+ context.openLineBelow();
+ return HandleResult.handled(new InsertState(context.text(), context.cursor()));
+ });
+ map.put('O', (state, context) -> {
+ context.openLineAbove();
+ return HandleResult.handled(new InsertState(context.text(), context.cursor()));
+ });
+ map.put('x', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.deleteCharUnderCursor();
+ }
+ context.clampCursorInNormalMode();
+ context.recordUndoState();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('d', (state, context) -> HandleResult
+ .handled(new NormalState(0, PendingOperator.DELETE, state.preferredColumn())));
+ map.put('y', (state, context) -> HandleResult
+ .handled(new NormalState(0, PendingOperator.YANK, state.preferredColumn())));
+ map.put('c', (state, context) -> HandleResult
+ .handled(new NormalState(0, PendingOperator.CHANGE, state.preferredColumn())));
+ map.put('p', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.pasteAfter();
+ }
+ context.clampCursorInNormalMode();
+ context.recordUndoState();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('P', (state, context) -> {
+ final int repeats = context.readCount(state.count());
+ for (int i = 0; i < repeats; i++) {
+ context.pasteBefore();
+ }
+ context.clampCursorInNormalMode();
+ context.recordUndoState();
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('v', (state, context) -> {
+ final int anchor = context.cursor();
+ context.updateVisualSelection(anchor);
+ return HandleResult.handled(new VisualState(anchor, state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('V', (state, context) -> {
+ final int anchorLine = context.getCursorPhysicalLine();
+ context.updateVisualLineSelection(anchorLine, anchorLine);
+ return HandleResult.handled(
+ new VisualLineState(anchorLine, anchorLine, state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('/', (state, context) -> HandleResult
+ .handled(new SearchState(new StringBuilder(), context.getLastSearchQuery(), SearchDirection.FORWARD)));
+ map.put('?', (state, context) -> HandleResult
+ .handled(new SearchState(new StringBuilder(), context.getLastSearchQuery(), SearchDirection.BACKWARD)));
+ map.put('n', (state, context) -> {
+ context.findNext(context.getLastSearchQuery(), SearchDirection.FORWARD);
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('N', (state, context) -> {
+ context.findNext(context.getLastSearchQuery(), SearchDirection.BACKWARD);
+ return HandleResult.handled(new NormalState(0, PendingOperator.NONE, state.preferredColumn()));
+ });
+ map.put('u', (state, context) -> HandleResult.handled(context.undoToNormalState()));
+ return map;
+ }
+
+ private Map<Character, MotionAction> createMotionActions() {
+ final Map<Character, MotionAction> map = new HashMap<>();
+ map.put('h', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ for (int i = 0; i < repeats; i++) {
+ context.moveLeft();
+ }
+ return preferredColumn;
+ });
+ map.put('j', (context, repeats, preferredColumn, clampInNormalMode) -> context.moveVertical(1, repeats,
+ preferredColumn, clampInNormalMode));
+ map.put('k', (context, repeats, preferredColumn, clampInNormalMode) -> context.moveVertical(-1, repeats,
+ preferredColumn, clampInNormalMode));
+ map.put('l', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ for (int i = 0; i < repeats; i++) {
+ context.moveRightNormal();
+ }
+ return preferredColumn;
+ });
+ map.put('w', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ for (int i = 0; i < repeats; i++) {
+ context.moveWordForward();
+ }
+ if (clampInNormalMode) {
+ context.clampCursorInNormalMode();
+ }
+ return preferredColumn;
+ });
+ map.put('b', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ for (int i = 0; i < repeats; i++) {
+ context.moveWordBackward();
+ }
+ return preferredColumn;
+ });
+ map.put('0', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ context.moveLineStart();
+ return preferredColumn;
+ });
+ map.put('$', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ context.moveLineEnd();
+ return preferredColumn;
+ });
+ map.put('G', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ context.moveDocumentEnd();
+ return preferredColumn;
+ });
+ map.put('g', (context, repeats, preferredColumn, clampInNormalMode) -> {
+ context.moveDocumentStart();
+ return preferredColumn;
+ });
+ return map;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/SearchModeHandler.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/SearchModeHandler.java
new file mode 100644
index 0000000..c3e20f2
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/SearchModeHandler.java
@@ -0,0 +1,42 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.search.SearchDirection;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.SearchState;
+import com.badlogic.gdx.Input;
+
+public class SearchModeHandler implements ModeHandler<SearchState> {
+ @Override
+ public HandleResult onKeyDown(final SearchState state, final KeyContext context) {
+ if (context.currentKeyEvent().keycode() == Input.Keys.ESCAPE) {
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+ return HandleResult.handled();
+ }
+
+ @Override
+ public HandleResult onKeyTyped(final SearchState state, final char character, final KeyContext context) {
+ if (character == '\r' || character == '\n') {
+ final String query = state.query().toString();
+ context.setLastSearchQuery(query);
+ context.invalidateSearchIterator();
+ context.findNext(query, state.direction());
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ if (character == '\b') {
+ if (state.query().length() > 0) {
+ state.query().setLength(state.query().length() - 1);
+ }
+ return HandleResult.handled(state);
+ }
+
+ if (character < 32 || character == 127) {
+ return HandleResult.handled(state);
+ }
+
+ state.query().append(character);
+ return HandleResult.handled(new SearchState(state.query(), state.lastQuery(), state.direction()));
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualLineModeHandler.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualLineModeHandler.java
new file mode 100644
index 0000000..7056e54
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualLineModeHandler.java
@@ -0,0 +1,92 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.PendingOperator;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualLineState;
+import com.badlogic.gdx.Input;
+import java.util.HashMap;
+import java.util.Map;
+
+public class VisualLineModeHandler implements ModeHandler<VisualLineState> {
+ private final Map<Character, CommandAction<VisualLineState>> commandActions = createCommandActions();
+
+ @Override
+ public HandleResult onKeyDown(final VisualLineState state, final KeyContext context) {
+ if (context.currentKeyEvent().keycode() == Input.Keys.ESCAPE) {
+ final int startLine = Math.min(state.anchorLine(), state.cursorLine());
+ context.setCursor(context.navigation().findLineStartByLine(context.text(), startLine));
+ context.clampCursorInNormalMode();
+ context.clearSelection();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ final Character commandCharacter = context.toCommandCharacter();
+ if (commandCharacter != null) {
+ return onKeyTyped(state, commandCharacter.charValue(), context).withSuppressTyped();
+ }
+
+ return HandleResult.handled();
+ }
+
+ @Override
+ public HandleResult onKeyTyped(final VisualLineState state, final char character, final KeyContext context) {
+ if (character >= '1' && character <= '9') {
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), state.cursorLine(),
+ state.preferredColumn(), state.pendingOperator(), state.count() * 10 + (character - '0')));
+ }
+ if (character == '0' && state.count() > 0) {
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), state.cursorLine(),
+ state.preferredColumn(), state.pendingOperator(), state.count() * 10));
+ }
+
+ if (state.pendingOperator() == PendingOperator.G_PREFIX) {
+ if (character == 'g') {
+ final int cursorLine = 0;
+ context.updateVisualLineSelection(state.anchorLine(), cursorLine);
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), cursorLine, state.preferredColumn(),
+ PendingOperator.NONE, 0));
+ }
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), state.cursorLine(),
+ state.preferredColumn(), PendingOperator.NONE, 0));
+ }
+
+ final CommandAction<VisualLineState> action = commandActions.get(character);
+ if (action == null) {
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), state.cursorLine(),
+ state.preferredColumn(), PendingOperator.NONE, 0));
+ }
+ return action.execute(new VisualLineState(state.anchorLine(), state.cursorLine(),
+ context.resetPreferredColumn(character, state.preferredColumn()), PendingOperator.NONE, state.count()),
+ context);
+ }
+
+ private Map<Character, CommandAction<VisualLineState>> createCommandActions() {
+ final Map<Character, CommandAction<VisualLineState>> map = new HashMap<>();
+ map.put('j', (state, context) -> {
+ final int cursorLine = context.adjustVisualLineCursor(state.cursorLine(), context.readCount(state.count()));
+ context.updateVisualLineSelection(state.anchorLine(), cursorLine);
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), cursorLine, state.preferredColumn(),
+ PendingOperator.NONE, 0));
+ });
+ map.put('k', (state, context) -> {
+ final int cursorLine = context.adjustVisualLineCursor(state.cursorLine(),
+ -context.readCount(state.count()));
+ context.updateVisualLineSelection(state.anchorLine(), cursorLine);
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), cursorLine, state.preferredColumn(),
+ PendingOperator.NONE, 0));
+ });
+ map.put('G', (state, context) -> {
+ final int cursorLine = context.maxSelectableLineIndex();
+ context.updateVisualLineSelection(state.anchorLine(), cursorLine);
+ return HandleResult.handled(new VisualLineState(state.anchorLine(), cursorLine, state.preferredColumn(),
+ PendingOperator.NONE, 0));
+ });
+ map.put('g', (state, context) -> HandleResult.handled(new VisualLineState(state.anchorLine(),
+ state.cursorLine(), state.preferredColumn(), PendingOperator.G_PREFIX, 0)));
+ map.put('d', (state, context) -> context.applyVisualLineOperator(state, PendingOperator.DELETE));
+ map.put('y', (state, context) -> context.applyVisualLineOperator(state, PendingOperator.YANK));
+ map.put('c', (state, context) -> context.applyVisualLineOperator(state, PendingOperator.CHANGE));
+ return map;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualModeHandler.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualModeHandler.java
new file mode 100644
index 0000000..db794d5
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/handler/VisualModeHandler.java
@@ -0,0 +1,132 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.handler;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.context.KeyContext;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.PendingOperator;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualState;
+import com.badlogic.gdx.Input;
+import java.util.HashMap;
+import java.util.Map;
+
+public class VisualModeHandler implements ModeHandler<VisualState> {
+ private final Map<Character, CommandAction<VisualState>> commandActions = createCommandActions();
+
+ @Override
+ public HandleResult onKeyDown(final VisualState state, final KeyContext context) {
+ if (context.currentKeyEvent().keycode() == Input.Keys.ESCAPE) {
+ context.setCursor(Math.min(state.anchor(), context.cursor()));
+ context.clampCursorInNormalMode();
+ context.clearSelection();
+ return HandleResult.handled(NormalState.DEFAULT);
+ }
+
+ final Character commandCharacter = context.toCommandCharacter();
+ if (commandCharacter != null) {
+ return onKeyTyped(state, commandCharacter.charValue(), context).withSuppressTyped();
+ }
+
+ return HandleResult.handled();
+ }
+
+ @Override
+ public HandleResult onKeyTyped(final VisualState state, final char character, final KeyContext context) {
+ if (character >= '1' && character <= '9') {
+ return HandleResult.handled(new VisualState(state.anchor(), state.preferredColumn(),
+ state.pendingOperator(), state.count() * 10 + (character - '0')));
+ }
+ if (character == '0' && state.count() > 0) {
+ return HandleResult.handled(new VisualState(state.anchor(), state.preferredColumn(),
+ state.pendingOperator(), state.count() * 10));
+ }
+
+ if (state.pendingOperator() == PendingOperator.G_PREFIX) {
+ if (character == 'g') {
+ context.moveDocumentStart();
+ context.updateVisualSelection(state.anchor());
+ }
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ }
+
+ final VisualState normalizedState = new VisualState(state.anchor(),
+ context.resetPreferredColumn(character, state.preferredColumn()), PendingOperator.NONE, state.count());
+ final CommandAction<VisualState> action = commandActions.get(character);
+ if (action == null) {
+ return HandleResult.handled(
+ new VisualState(state.anchor(), normalizedState.preferredColumn(), PendingOperator.NONE, 0));
+ }
+ return action.execute(normalizedState, context);
+ }
+
+ private Map<Character, CommandAction<VisualState>> createCommandActions() {
+ final Map<Character, CommandAction<VisualState>> map = new HashMap<>();
+ map.put('h', (state, context) -> {
+ for (int i = 0; i < context.readCount(state.count()); i++) {
+ context.moveLeft();
+ }
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('j', (state, context) -> {
+ final int preferred = context.moveVertical(1, context.readCount(state.count()), state.preferredColumn(),
+ false);
+ context.updateVisualSelection(state.anchor());
+ return HandleResult.handled(new VisualState(state.anchor(), preferred, PendingOperator.NONE, 0));
+ });
+ map.put('k', (state, context) -> {
+ final int preferred = context.moveVertical(-1, context.readCount(state.count()), state.preferredColumn(),
+ false);
+ context.updateVisualSelection(state.anchor());
+ return HandleResult.handled(new VisualState(state.anchor(), preferred, PendingOperator.NONE, 0));
+ });
+ map.put('l', (state, context) -> {
+ for (int i = 0; i < context.readCount(state.count()); i++) {
+ context.moveRightNormal();
+ }
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('w', (state, context) -> {
+ for (int i = 0; i < context.readCount(state.count()); i++) {
+ context.moveWordForward();
+ }
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('b', (state, context) -> {
+ for (int i = 0; i < context.readCount(state.count()); i++) {
+ context.moveWordBackward();
+ }
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('0', (state, context) -> {
+ context.moveLineStart();
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('$', (state, context) -> {
+ context.moveLineEnd();
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('G', (state, context) -> {
+ context.moveDocumentEnd();
+ context.updateVisualSelection(state.anchor());
+ return HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.NONE, 0));
+ });
+ map.put('g', (state, context) -> HandleResult
+ .handled(new VisualState(state.anchor(), state.preferredColumn(), PendingOperator.G_PREFIX, 0)));
+ map.put('d', (state, context) -> context.applyVisualOperator(state, PendingOperator.DELETE));
+ map.put('y', (state, context) -> context.applyVisualOperator(state, PendingOperator.YANK));
+ map.put('c', (state, context) -> context.applyVisualOperator(state, PendingOperator.CHANGE));
+ return map;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/Register.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/Register.java
new file mode 100644
index 0000000..9bacd59
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/Register.java
@@ -0,0 +1,11 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.model;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class Register {
+ private String text = "";
+ private RegisterType type = RegisterType.CHARACTER;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/RegisterType.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/RegisterType.java
new file mode 100644
index 0000000..9ecb900
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/RegisterType.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.model;
+
+public enum RegisterType {
+ CHARACTER, LINE
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoManager.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoManager.java
new file mode 100644
index 0000000..34b2122
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoManager.java
@@ -0,0 +1,52 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.model;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import lombok.RequiredArgsConstructor;
+
+@RequiredArgsConstructor
+public class UndoManager {
+ private final int maxUndo;
+ private final Deque<UndoState> undoStack = new ArrayDeque<>();
+ private final Deque<UndoState> redoStack = new ArrayDeque<>();
+
+ public void record(final String text, final int cursor) {
+ final UndoState candidate = new UndoState(text, cursor);
+ final UndoState top = undoStack.peekLast();
+ if (top != null && top.getCursor() == candidate.getCursor() && top.getText().equals(candidate.getText())) {
+ return;
+ }
+
+ undoStack.addLast(candidate);
+ while (undoStack.size() > maxUndo) {
+ undoStack.removeFirst();
+ }
+ redoStack.clear();
+ }
+
+ public UndoState undo() {
+ if (undoStack.size() <= 1) {
+ return null;
+ }
+
+ final UndoState current = undoStack.removeLast();
+ redoStack.addLast(current);
+ return undoStack.peekLast();
+ }
+
+ public UndoState redo() {
+ if (redoStack.isEmpty()) {
+ return null;
+ }
+
+ final UndoState target = redoStack.removeLast();
+ final UndoState top = undoStack.peekLast();
+ if (top == null || !top.getText().equals(target.getText()) || top.getCursor() != target.getCursor()) {
+ undoStack.addLast(target);
+ while (undoStack.size() > maxUndo) {
+ undoStack.removeFirst();
+ }
+ }
+ return target;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoState.java
new file mode 100644
index 0000000..c91ebcd
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/model/UndoState.java
@@ -0,0 +1,11 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.model;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public class UndoState {
+ private final String text;
+ private final int cursor;
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/navigation/TextNavigation.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/navigation/TextNavigation.java
new file mode 100644
index 0000000..c9ef857
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/navigation/TextNavigation.java
@@ -0,0 +1,110 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.navigation;
+
+public class TextNavigation {
+ public int findLineStart(final String text, final int cursor) {
+ int index = Math.max(0, Math.min(cursor, text.length()));
+ while (index > 0 && text.charAt(index - 1) != '\n') {
+ index--;
+ }
+ return index;
+ }
+
+ public int findLineEnd(final String text, final int cursor) {
+ final int lineStart = findLineStart(text, cursor);
+ int index = lineStart;
+ while (index < text.length() && text.charAt(index) != '\n') {
+ index++;
+ }
+ if (index == lineStart) {
+ return lineStart;
+ }
+ return index - 1;
+ }
+
+ public int findLineEndExclusiveByStart(final String text, final int lineStart) {
+ int index = lineStart;
+ while (index < text.length() && text.charAt(index) != '\n') {
+ index++;
+ }
+ return index;
+ }
+
+ public int findNextLineStart(final String text, final int start) {
+ final int newline = text.indexOf('\n', Math.max(0, start));
+ if (newline < 0) {
+ return -1;
+ }
+ return newline + 1;
+ }
+
+ public int findPreviousLineStart(final String text, final int lineStart) {
+ final int clampedLineStart = Math.max(0, Math.min(lineStart, text.length()));
+ if (clampedLineStart <= 0) {
+ return -1;
+ }
+ final int previousNewline = text.lastIndexOf('\n', clampedLineStart - 2);
+ return previousNewline + 1;
+ }
+
+ public int findBelowLineInsertionIndex(final String text, final int cursor) {
+ final int lineStart = findLineStart(text, cursor);
+ final int lineEnd = findLineEnd(text, cursor);
+ final boolean currentLineIsEmpty = findLineEndExclusiveByStart(text, lineStart) == lineStart;
+ int insertAt = Math.min(lineEnd + 1, text.length());
+ if (!currentLineIsEmpty && insertAt < text.length() && text.charAt(insertAt) == '\n') {
+ insertAt++;
+ }
+ return insertAt;
+ }
+
+ public int findLineStartByLine(final String text, final int lineNumber) {
+ int line = 0;
+ int index = 0;
+ while (line < lineNumber && index < text.length()) {
+ if (text.charAt(index) == '\n') {
+ line++;
+ }
+ index++;
+ }
+ return index;
+ }
+
+ public int findLineSelectionEndByLine(final String text, final int lineNumber) {
+ int index = findLineStartByLine(text, lineNumber);
+ while (index < text.length() && text.charAt(index) != '\n') {
+ index++;
+ }
+ if (index < text.length() && text.charAt(index) == '\n') {
+ return index + 1;
+ }
+ return text.length();
+ }
+
+ public int lastNavigablePosition(final String text) {
+ if (text.isEmpty()) {
+ return 0;
+ }
+
+ return text.length() - 1;
+ }
+
+ public int maxCursorPositionInNormalMode(final String text) {
+ if (text.isEmpty()) {
+ return 0;
+ }
+ return text.length() - 1;
+ }
+
+ public boolean isWordCharacter(final char character) {
+ return Character.isLetterOrDigit(character) || character == '_';
+ }
+
+ public boolean isInlineWhitespace(final char character) {
+ return Character.isWhitespace(character) && character != '\n' && character != '\r';
+ }
+
+ public boolean isPunctuationWord(final char character) {
+ return !isWordCharacter(character) && !Character.isWhitespace(character) && character != '\n'
+ && character != '\r';
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchDirection.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchDirection.java
new file mode 100644
index 0000000..ca3d63e
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchDirection.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.search;
+
+public enum SearchDirection {
+ FORWARD, BACKWARD
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchIterator.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchIterator.java
new file mode 100644
index 0000000..50f0e1c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/search/SearchIterator.java
@@ -0,0 +1,58 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.search;
+
+import java.util.OptionalInt;
+
+public class SearchIterator {
+ private final String text;
+ private final String query;
+ private final SearchDirection direction;
+ private final boolean wrap;
+ private int nextStart;
+ private boolean wrapped;
+
+ public SearchIterator(final String text, final String query, final int startIndex, final SearchDirection direction,
+ final boolean wrap) {
+ this.text = text == null ? "" : text;
+ this.query = query == null ? "" : query;
+ this.direction = direction;
+ this.wrap = wrap;
+ if (direction == SearchDirection.FORWARD) {
+ this.nextStart = Math.min(this.text.length(), Math.max(0, startIndex + 1));
+ } else {
+ this.nextStart = Math.max(0, startIndex - 1);
+ }
+ this.wrapped = false;
+ }
+
+ public OptionalInt nextMatch() {
+ if (query.isEmpty() || text.isEmpty()) {
+ return OptionalInt.empty();
+ }
+
+ if (direction == SearchDirection.FORWARD) {
+ final int found = text.indexOf(query, nextStart);
+ if (found >= 0) {
+ nextStart = Math.min(text.length(), found + 1);
+ return OptionalInt.of(found);
+ }
+ if (wrap && !wrapped) {
+ wrapped = true;
+ nextStart = 0;
+ return nextMatch();
+ }
+ return OptionalInt.empty();
+ }
+
+ final int found = text.lastIndexOf(query, nextStart);
+ if (found >= 0) {
+ nextStart = Math.max(0, found - 1);
+ return OptionalInt.of(found);
+ }
+ if (wrap && !wrapped) {
+ wrapped = true;
+ nextStart = Math.max(0, text.length() - 1);
+ return nextMatch();
+ }
+ return OptionalInt.empty();
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/InsertState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/InsertState.java
new file mode 100644
index 0000000..74537c9
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/InsertState.java
@@ -0,0 +1,4 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+public record InsertState(String insertStartText, int insertStartCursor) implements ModeState {
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/ModeState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/ModeState.java
new file mode 100644
index 0000000..78d2fb8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/ModeState.java
@@ -0,0 +1,15 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+public sealed interface ModeState permits InsertState, NormalState, SearchState, VisualLineState, VisualState {
+ default PendingOperator pendingOperator() {
+ return PendingOperator.NONE;
+ }
+
+ default int count() {
+ return 0;
+ }
+
+ default int preferredColumn() {
+ return -1;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/NormalState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/NormalState.java
new file mode 100644
index 0000000..42b1d99
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/NormalState.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+public record NormalState(int count, PendingOperator pendingOperator, int preferredColumn) implements ModeState {
+ public static final NormalState DEFAULT = new NormalState(0, PendingOperator.NONE, -1);
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/PendingOperator.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/PendingOperator.java
new file mode 100644
index 0000000..1fc4425
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/PendingOperator.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+public enum PendingOperator {
+ NONE, DELETE, YANK, CHANGE, G_PREFIX
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/SearchState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/SearchState.java
new file mode 100644
index 0000000..7de6811
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/SearchState.java
@@ -0,0 +1,11 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.search.SearchDirection;
+
+public record SearchState(StringBuilder query, String lastQuery, SearchDirection direction) implements ModeState {
+ public SearchState {
+ query = query == null ? new StringBuilder() : query;
+ lastQuery = lastQuery == null ? "" : lastQuery;
+ direction = direction == null ? SearchDirection.FORWARD : direction;
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualLineState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualLineState.java
new file mode 100644
index 0000000..b486223
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualLineState.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+public record VisualLineState(int anchorLine, int cursorLine, int preferredColumn, PendingOperator pendingOperator,
+ int count) implements ModeState {
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualState.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualState.java
new file mode 100644
index 0000000..97604c8
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/state/VisualState.java
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.state;
+
+public record VisualState(int anchor, int preferredColumn, PendingOperator pendingOperator,
+ int count) implements ModeState {
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/status/StatusFormatter.java b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/status/StatusFormatter.java
new file mode 100644
index 0000000..4ef8c17
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/ui/editor/vim/status/StatusFormatter.java
@@ -0,0 +1,56 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim.status;
+
+import coffee.liz.abstractionengine.app.ui.editor.vim.VimConfig;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.InsertState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.ModeState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.NormalState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.PendingOperator;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.SearchState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualLineState;
+import coffee.liz.abstractionengine.app.ui.editor.vim.state.VisualState;
+
+public class StatusFormatter {
+ public String format(final ModeState state, final VimConfig config) {
+ return switch (state) {
+ case NormalState normalState -> formatNormal(normalState, config);
+ case InsertState ignored -> config.insertStatusLabel();
+ case VisualState ignored -> config.visualStatusLabel();
+ case VisualLineState ignored -> config.visualLineStatusLabel();
+ case SearchState searchState -> '/' + searchState.query().toString();
+ };
+ }
+
+ private String formatNormal(final NormalState state, final VimConfig config) {
+ final StringBuilder builder = new StringBuilder(config.normalStatusLabel());
+ appendOperator(builder, state.pendingOperator());
+ if (state.count() > 0) {
+ builder.append(" count=").append(state.count());
+ }
+ return builder.toString();
+ }
+
+ private void appendOperator(final StringBuilder builder, final PendingOperator pendingOperator) {
+ if (pendingOperator == PendingOperator.NONE) {
+ return;
+ }
+
+ builder.append(" [");
+ switch (pendingOperator) {
+ case DELETE :
+ builder.append('d');
+ break;
+ case YANK :
+ builder.append('y');
+ break;
+ case CHANGE :
+ builder.append('c');
+ break;
+ case G_PREFIX :
+ builder.append('g');
+ break;
+ default :
+ break;
+ }
+ builder.append(']');
+ }
+}
diff --git a/core/src/main/java/coffee/liz/abstractionengine/app/utils/FontHelper.java b/core/src/main/java/coffee/liz/abstractionengine/app/utils/FontHelper.java
new file mode 100644
index 0000000..b38f25c
--- /dev/null
+++ b/core/src/main/java/coffee/liz/abstractionengine/app/utils/FontHelper.java
@@ -0,0 +1,32 @@
+package coffee.liz.abstractionengine.app.utils;
+
+import coffee.liz.abstractionengine.app.AbstractionEngineGame;
+import com.badlogic.gdx.Gdx;
+import com.badlogic.gdx.graphics.Texture;
+import com.badlogic.gdx.graphics.g2d.BitmapFont;
+import com.badlogic.gdx.graphics.g2d.freetype.FreeTypeFontGenerator;
+
+public final class FontHelper {
+ private FontHelper() {
+ }
+
+ public static final String RENDERABLE_CHARS = "λ" + FreeTypeFontGenerator.DEFAULT_CHARS;
+ public static final String MONO = "fonts/JetBrainsMonoNerdFont-Regular.ttf";
+
+ public static BitmapFont initFont(final int size, final String path, final float scale) {
+ final int scaleForHidpi = 4;
+ final FreeTypeFontGenerator gen = new FreeTypeFontGenerator(Gdx.files.internal(path));
+ final FreeTypeFontGenerator.FreeTypeFontParameter params = new FreeTypeFontGenerator.FreeTypeFontParameter();
+ params.characters = RENDERABLE_CHARS;
+ params.size = scaleForHidpi * size;
+
+ final BitmapFont font = gen.generateFont(params);
+ font.setFixedWidthGlyphs(RENDERABLE_CHARS);
+ font.setUseIntegerPositions(false);
+ font.getRegion().getTexture().setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
+ font.getData().setScale((1f / scaleForHidpi) * scale);
+
+ gen.dispose();
+ return font;
+ }
+}
diff --git a/core/src/main/java/com/kotcrab/vis/ui/widget/EditorHighlightTextAreaBridge.java b/core/src/main/java/com/kotcrab/vis/ui/widget/EditorHighlightTextAreaBridge.java
new file mode 100644
index 0000000..a14c6a7
--- /dev/null
+++ b/core/src/main/java/com/kotcrab/vis/ui/widget/EditorHighlightTextAreaBridge.java
@@ -0,0 +1,33 @@
+package com.kotcrab.vis.ui.widget;
+
+public class EditorHighlightTextAreaBridge extends HighlightTextArea {
+ public EditorHighlightTextAreaBridge(final String text, final VisTextFieldStyle style) {
+ super(text, style);
+ }
+
+ public void editorSyncCursorState() {
+ updateCurrentLine();
+ showCursor();
+ }
+
+ public void editorSetSelectionEndpoints(final int selectionStart, final int cursorPosition) {
+ final int clampedSelectionStart = Math.max(0, Math.min(selectionStart, getText().length()));
+ final int clampedCursorPosition = Math.max(0, Math.min(cursorPosition, getText().length()));
+ if (clampedSelectionStart == clampedCursorPosition) {
+ clearSelection();
+ cursor = clampedCursorPosition;
+ editorSyncCursorState();
+ return;
+ }
+
+ hasSelection = true;
+ this.selectionStart = clampedSelectionStart;
+ cursor = clampedCursorPosition;
+ editorSyncCursorState();
+ }
+
+ public void editorSetCursorKeepingSelection(final int cursorPosition) {
+ cursor = Math.max(0, Math.min(cursorPosition, getText().length()));
+ editorSyncCursorState();
+ }
+}
diff --git a/core/src/test/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngineTest.java b/core/src/test/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngineTest.java
new file mode 100644
index 0000000..31d823a
--- /dev/null
+++ b/core/src/test/java/coffee/liz/abstractionengine/app/ui/editor/vim/VimEngineTest.java
@@ -0,0 +1,578 @@
+package coffee.liz.abstractionengine.app.ui.editor.vim;
+
+import coffee.liz.abstractionengine.app.ui.editor.CursorStyle;
+import coffee.liz.abstractionengine.app.ui.editor.KeyEvent;
+import coffee.liz.abstractionengine.app.ui.editor.LineNumberMode;
+import coffee.liz.abstractionengine.app.ui.editor.EditorModifier;
+import com.badlogic.gdx.Input;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class VimEngineTest {
+ private static class FakeBuffer implements EditorBuffer {
+ private String text;
+ private int cursor;
+ private int selectionStart;
+ private int selectionEnd;
+ private boolean hasSelection;
+
+ private FakeBuffer(final String text, final int cursor) {
+ this.text = text;
+ this.cursor = Math.max(0, Math.min(cursor, text.length()));
+ this.selectionStart = 0;
+ this.selectionEnd = 0;
+ this.hasSelection = false;
+ }
+
+ @Override
+ public String getText() {
+ return text;
+ }
+
+ @Override
+ public void setText(final String text) {
+ this.text = text;
+ this.cursor = Math.max(0, Math.min(cursor, text.length()));
+ if (hasSelection) {
+ this.selectionStart = Math.max(0, Math.min(selectionStart, text.length()));
+ this.selectionEnd = Math.max(0, Math.min(selectionEnd, text.length()));
+ this.hasSelection = selectionStart != selectionEnd;
+ }
+ }
+
+ @Override
+ public int getCursorPosition() {
+ return cursor;
+ }
+
+ @Override
+ public void setCursorPosition(final int cursorPosition) {
+ this.cursor = Math.max(0, Math.min(cursorPosition, text.length()));
+ }
+
+ @Override
+ public int getCursorLine() {
+ int line = 0;
+ for (int i = 0; i < Math.min(cursor, text.length()); i++) {
+ if (text.charAt(i) == '\n') {
+ line++;
+ }
+ }
+ return line;
+ }
+
+ @Override
+ public int getLines() {
+ if (text.isEmpty()) {
+ return 1;
+ }
+ int lines = 1;
+ for (int i = 0; i < text.length(); i++) {
+ if (text.charAt(i) == '\n') {
+ lines++;
+ }
+ }
+ return lines;
+ }
+
+ @Override
+ public void moveCursorLine(final int line) {
+ final int targetLine = Math.max(0, Math.min(line, getLines() - 1));
+ final int currentLineStart = findLineStartByNumber(getCursorLine());
+ final int currentColumn = cursor - currentLineStart;
+ final int targetStart = findLineStartByNumber(targetLine);
+ final int targetEndExclusive = findLineEndExclusive(targetStart);
+ this.cursor = Math.min(targetStart + currentColumn, targetEndExclusive);
+ }
+
+ @Override
+ public void setSelectionEndpoints(final int selectionStart, final int cursorPosition) {
+ final int clampedSelectionStart = Math.max(0, Math.min(selectionStart, text.length()));
+ this.cursor = Math.max(0, Math.min(cursorPosition, text.length()));
+ this.selectionStart = Math.min(clampedSelectionStart, this.cursor);
+ this.selectionEnd = Math.max(clampedSelectionStart, this.cursor);
+ this.hasSelection = this.selectionStart != this.selectionEnd;
+ }
+
+ @Override
+ public void clearSelection() {
+ this.hasSelection = false;
+ this.selectionStart = 0;
+ this.selectionEnd = 0;
+ }
+
+ private int findLineStartByNumber(final int targetLine) {
+ int line = 0;
+ int index = 0;
+ while (line < targetLine && index < text.length()) {
+ if (text.charAt(index) == '\n') {
+ line++;
+ }
+ index++;
+ }
+ return index;
+ }
+
+ private int findLineEndExclusive(final int lineStart) {
+ int index = lineStart;
+ while (index < text.length() && text.charAt(index) != '\n') {
+ index++;
+ }
+ return index;
+ }
+ }
+
+ @Test
+ public void visualSelectionAndEscapeRestoresCursorToSelectionStart() {
+ final FakeBuffer buffer = new FakeBuffer("abcdef\n", 1);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'v', 'l', 'l');
+
+ assertEquals(1, buffer.selectionStart);
+ assertEquals(3, buffer.selectionEnd);
+ assertEquals(3, buffer.getCursorPosition());
+
+ controller.handleKeyDown(Input.Keys.ESCAPE);
+
+ assertFalse(buffer.hasSelection);
+ assertEquals(1, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void normalModeUsesBlockCursorAndRelativeLines() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ assertEquals(CursorStyle.BLOCK, controller.getRenderState().cursorStyle());
+ assertEquals(LineNumberMode.RELATIVE, controller.getRenderState().lineNumberMode());
+ }
+
+ @Test
+ public void insertModeUsesCaretAndAbsoluteLines() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'i');
+
+ assertEquals(CursorStyle.CARET, controller.getRenderState().cursorStyle());
+ assertEquals(LineNumberMode.ABSOLUTE, controller.getRenderState().lineNumberMode());
+ }
+
+ @Test
+ public void enteringVisualModeDoesNotMoveCursor() {
+ final FakeBuffer buffer = new FakeBuffer("abcdef", 2);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('v');
+
+ assertEquals(2, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void visualModeCanMoveLeftToShrinkSelection() {
+ final FakeBuffer buffer = new FakeBuffer("abcdef", 1);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'v', 'l', 'l');
+ assertEquals(1, buffer.selectionStart);
+ assertEquals(3, buffer.selectionEnd);
+
+ type(controller, 'h');
+ assertEquals(1, buffer.selectionStart);
+ assertEquals(2, buffer.selectionEnd);
+ assertEquals(2, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void visualBackwardSelectionIncludesAnchorCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("abcdef", 3);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'v', 'h');
+
+ assertTrue(buffer.hasSelection);
+ assertEquals("cd", buffer.getText().substring(buffer.selectionStart, buffer.selectionEnd));
+ }
+
+ @Test
+ public void visualZeroMotionBackwardIncludesAnchorCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("abcdef", 3);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'v', '0');
+
+ assertTrue(buffer.hasSelection);
+ assertEquals("abcd", buffer.getText().substring(buffer.selectionStart, buffer.selectionEnd));
+ }
+
+ @Test
+ public void visualModeGgMovesToStartAndKeepsSelection() {
+ final FakeBuffer buffer = new FakeBuffer("one\ntwo\nthree", 7);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'v', 'g', 'g');
+
+ assertEquals(0, buffer.getCursorPosition());
+ assertTrue(buffer.hasSelection);
+ assertEquals("one\ntwo\n", buffer.getText().substring(buffer.selectionStart, buffer.selectionEnd));
+ }
+
+ @Test
+ public void visualLineSelectionWorksWithShiftV() {
+ final FakeBuffer buffer = new FakeBuffer("one\ntwo\nthree\n", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'V', 'j');
+
+ assertTrue(buffer.hasSelection);
+ assertEquals(0, buffer.selectionStart);
+ assertEquals(8, buffer.selectionEnd);
+ }
+
+ @Test
+ public void visualLineJThenKUpdatesSelectionAndCursorLine() {
+ final FakeBuffer buffer = new FakeBuffer("one\ntwo\nthree\n", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'V', 'j');
+ assertEquals(0, buffer.selectionStart);
+ assertEquals(8, buffer.selectionEnd);
+
+ type(controller, 'k');
+ assertEquals(0, buffer.selectionStart);
+ assertEquals(4, buffer.selectionEnd);
+ assertEquals(0, buffer.getCursorLine());
+ }
+
+ @Test
+ public void visualLineModeDoesNotMoveCursorPastLastRealTrailingEmptyLine() {
+ final FakeBuffer buffer = new FakeBuffer("a\n\n\n", 2);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'V', 'j');
+
+ assertEquals(3, buffer.getCursorPosition());
+ assertEquals(2, buffer.getCursorLine());
+ }
+
+ @Test
+ public void openLineBelowPlacesCursorOnNewLine() {
+ final FakeBuffer buffer = new FakeBuffer("abc\ndef", 1);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('o');
+
+ assertEquals("abc\n\ndef", buffer.getText());
+ assertEquals(4, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void moveDownCanReachTrailingEmptyLines() {
+ final FakeBuffer buffer = new FakeBuffer("a\n\n\n", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'j', 'j', 'j');
+
+ assertEquals(3, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void openLineBelowOnTrailingEmptyLineMovesOneLineDown() {
+ final FakeBuffer buffer = new FakeBuffer("a\n\n\n", 2);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('o');
+
+ assertEquals("a\n\n\n\n", buffer.getText());
+ assertEquals(3, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void gMovesToLastNavigableCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("alpha\nbeta\n", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('G');
+
+ assertEquals(9, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void ggMovesToTopOfFile() {
+ final FakeBuffer buffer = new FakeBuffer("alpha\nbeta\n", 9);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'g', 'g');
+
+ assertEquals(0, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void ddThenPastePlacesLineBelow() {
+ final FakeBuffer buffer = new FakeBuffer("a\nb", 2);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'd', 'd', 'p');
+
+ assertEquals("a\nb\n", buffer.getText());
+ }
+
+ @Test
+ public void yyThenPastePlacesLineBelow() {
+ final FakeBuffer buffer = new FakeBuffer("one\ntwo\n", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'y', 'y', 'p');
+
+ assertEquals("one\none\ntwo\n", buffer.getText());
+ }
+
+ @Test
+ public void dwDeletesWord() {
+ final FakeBuffer buffer = new FakeBuffer("one two", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'd', 'w');
+
+ assertEquals("two", buffer.getText());
+ }
+
+ @Test
+ public void dlDeletesExactlyOneCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("abcd", 1);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'd', 'l');
+
+ assertEquals("acd", buffer.getText());
+ }
+
+ @Test
+ public void dlOnLastCharacterDeletesLastCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("abcd", 3);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'd', 'l');
+
+ assertEquals("abc", buffer.getText());
+ assertEquals(2, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void undoRevertsLatestEdit() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('x');
+ assertEquals("bc", buffer.getText());
+
+ controller.handleKeyTyped('u');
+ assertEquals("abc", buffer.getText());
+ assertEquals(0, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void ctrlRRedoesAfterUndo() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('x');
+ controller.handleKeyTyped('u');
+ controller.handleKeyDown(Input.Keys.R, true);
+
+ assertEquals("bc", buffer.getText());
+ }
+
+ @Test
+ public void keyDownMotionReturnsSuppressTypedAndMovesCursor() {
+ final FakeBuffer buffer = new FakeBuffer("abc\ndef", 1);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ final boolean handled = controller.onKeyDown(new KeyEvent(Input.Keys.J, Set.of()));
+
+ assertTrue(handled);
+ assertEquals(5, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void keyDownShiftVStartsVisualLineMode() {
+ final FakeBuffer buffer = new FakeBuffer("one\ntwo\nthree\n", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ final boolean handled = controller.onKeyDown(new KeyEvent(Input.Keys.V, Set.of(EditorModifier.SHIFT)));
+
+ assertTrue(handled);
+ assertEquals("-- VISUAL LINE --", controller.getStatusText());
+ }
+
+ @Test
+ public void controlCharacterRRedoesAfterUndo() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('x');
+ controller.handleKeyTyped('u');
+ controller.handleKeyTyped((char) 18);
+
+ assertEquals("bc", buffer.getText());
+ }
+
+ @Test
+ public void searchAndNextFindMatches() {
+ final FakeBuffer buffer = new FakeBuffer("cat dog cat", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('/');
+ controller.handleKeyTyped('c');
+ controller.handleKeyTyped('a');
+ controller.handleKeyTyped('t');
+ controller.handleKeyTyped('\n');
+
+ assertEquals(8, buffer.getCursorPosition());
+
+ controller.handleKeyTyped('n');
+ assertEquals(0, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void countMotionAdvancesMultipleCharacters() {
+ final FakeBuffer buffer = new FakeBuffer("abcdef", 0);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, '3', 'l');
+
+ assertEquals(3, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void bStopsAtPunctuationWordBeforeSymbols() {
+ final FakeBuffer buffer = new FakeBuffer("let lambda = ", 12);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'b');
+
+ assertEquals(11, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void wStopsAtPunctuationWordBeforeSymbols() {
+ final FakeBuffer buffer = new FakeBuffer("let lambda = ", 4);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'w');
+
+ assertEquals(11, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void secondWFromPunctuationMovesToNextIdentifier() {
+ final FakeBuffer buffer = new FakeBuffer("let lambda = x", 4);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'w', 'w');
+
+ assertEquals(13, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void secondBFromPunctuationMovesToPreviousIdentifier() {
+ final FakeBuffer buffer = new FakeBuffer("let lambda = ", 12);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'b', 'b');
+
+ assertEquals(4, buffer.getCursorPosition());
+ }
+
+ @Test
+ public void appendMovesPastCurrentCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 1);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('a');
+ insertCharacter(buffer, 'X');
+ controller.handleKeyDown(Input.Keys.ESCAPE);
+
+ assertEquals("abXc", buffer.getText());
+ }
+
+ @Test
+ public void appendAtEndOfLineInsertsAfterLastCharacter() {
+ final FakeBuffer buffer = new FakeBuffer("abc", 2);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ controller.handleKeyTyped('a');
+ insertCharacter(buffer, 'X');
+ controller.handleKeyDown(Input.Keys.ESCAPE);
+
+ assertEquals("abcX", buffer.getText());
+ }
+
+ @Test
+ public void verticalMoveUsesCurrentColumnNotStaleColumn() {
+ final FakeBuffer buffer = new FakeBuffer("12345678\n12\n12345678\n", 6);
+ final VimEngine controller = new VimEngine(buffer, status -> {
+ });
+
+ type(controller, 'j');
+ assertEquals(10, buffer.getCursorPosition());
+
+ type(controller, 'h');
+ assertEquals(9, buffer.getCursorPosition());
+
+ type(controller, 'k');
+ assertEquals(0, buffer.getCursorPosition());
+ }
+
+ private static void type(final VimEngine controller, final char... chars) {
+ for (final char character : chars) {
+ controller.handleKeyTyped(character);
+ }
+ }
+
+ private static void insertCharacter(final FakeBuffer buffer, final char character) {
+ final int cursor = buffer.getCursorPosition();
+ final String text = buffer.getText();
+ buffer.setText(text.substring(0, cursor) + character + text.substring(cursor));
+ buffer.setCursorPosition(cursor + 1);
+ }
+}