diff options
Diffstat (limited to 'core')
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); + } +} |
