Initial commit
This commit is contained in:
commit
6c0c289b52
29
actions.go
Normal file
29
actions.go
Normal file
@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
// Action is the base interface - anything executable
|
||||
type Action interface {
|
||||
Execute(m *model) tea.Cmd
|
||||
}
|
||||
|
||||
// Motion moves the cursor and returns the range covered
|
||||
type Motion interface {
|
||||
Action
|
||||
}
|
||||
|
||||
// Operator acts on a range (delete, yank, change)
|
||||
type Operator interface {
|
||||
Operate(m *model, start, end Position) tea.Cmd
|
||||
// DoublePress handles dd, yy, cc (line-wise)
|
||||
DoublePress(m *model) tea.Cmd
|
||||
}
|
||||
|
||||
// Repeatable actions track count
|
||||
type Repeatable interface {
|
||||
WithCount(n int) Action
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
Line, Col int
|
||||
}
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770197578,
|
||||
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
50
flake.nix
Normal file
50
flake.nix
Normal file
@ -0,0 +1,50 @@
|
||||
{
|
||||
description = "Go development flake. Adjust as needed.";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
# Define the development shell.
|
||||
# When you run `nix develop` (or direnv activates), you'll enter this shell.
|
||||
devShells.default = pkgs.mkShell {
|
||||
# List all the development tools you need available in this shell's PATH.
|
||||
packages = with pkgs; [
|
||||
go
|
||||
gopls
|
||||
go-tools
|
||||
gcc_multi
|
||||
glibc_multi
|
||||
];
|
||||
|
||||
# Define the shell that will be executed.
|
||||
# Here, we explicitly use zsh.
|
||||
# Note: pkgs.zsh needs to be included in `packages` or `nativeBuildInputs`
|
||||
# for it to be found in the shell's environment. `inherit pkgs.zsh;` is concise.
|
||||
inherit (pkgs) zsh;
|
||||
|
||||
# Environment variables and commands to run when the shell starts.
|
||||
shellHook = ''
|
||||
# Use the .local directory instead of home
|
||||
export GOPATH="$HOME/.local/go"
|
||||
echo "Settings GOPATH to: $HOME/.local/go "
|
||||
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export CGO_CFLAGS=-Wno-error=cpp;
|
||||
|
||||
# Exec zsh to replace the current shell process with zsh.
|
||||
# This ensures your prompt and zsh configurations load correctly.
|
||||
exec zsh
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
31
go.mod
Normal file
31
go.mod
Normal file
@ -0,0 +1,31 @@
|
||||
module git.gophernest.net/azpect/TextEditor
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.5 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
48
go.sum
Normal file
48
go.sum
Normal file
@ -0,0 +1,48 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4=
|
||||
github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
205
input.go
Normal file
205
input.go
Normal file
@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type InputState int
|
||||
|
||||
const (
|
||||
StateReady InputState = iota
|
||||
StateCount
|
||||
StateOperatorPending
|
||||
StateMotionCount
|
||||
)
|
||||
|
||||
type InputHandler struct {
|
||||
state InputState
|
||||
count1 int
|
||||
count2 int
|
||||
operator Operator
|
||||
operatorKey string // track which key started operator (for dd, yy, cc)
|
||||
buffer string // for display (what user has typed)
|
||||
pending string // partial key sequence (e.g., "g" waiting for second key)
|
||||
keymap *Keymap
|
||||
}
|
||||
|
||||
func NewInputHandler() *InputHandler {
|
||||
return &InputHandler{
|
||||
keymap: NewNormalKeymap(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *InputHandler) Handle(m *model, key string) tea.Cmd {
|
||||
// ESC always resets everything
|
||||
if key == "esc" {
|
||||
h.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to accumulate count (only if no pending sequence)
|
||||
if h.pending == "" && h.tryAccumulateCount(key) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the sequence (pending + new key)
|
||||
sequence := h.pending + key
|
||||
|
||||
// Check for exact match with full sequence
|
||||
kind, binding := h.keymap.Lookup(sequence)
|
||||
if kind != "" {
|
||||
h.pending = ""
|
||||
h.buffer += key
|
||||
return h.dispatch(m, kind, binding, sequence)
|
||||
}
|
||||
|
||||
// No exact match - could this be a prefix of something?
|
||||
if h.keymap.HasPrefix(sequence) {
|
||||
h.pending = sequence
|
||||
h.buffer += key
|
||||
return nil // wait for more keys
|
||||
}
|
||||
|
||||
// Not a prefix either - if we had pending, try just the new key
|
||||
if h.pending != "" {
|
||||
h.pending = ""
|
||||
kind, binding = h.keymap.Lookup(key)
|
||||
if kind != "" {
|
||||
h.buffer = key
|
||||
return h.dispatch(m, kind, binding, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing matched
|
||||
h.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch routes to the right handler based on current state
|
||||
func (h *InputHandler) dispatch(m *model, kind string, binding any, key string) tea.Cmd {
|
||||
switch h.state {
|
||||
case StateReady, StateCount:
|
||||
return h.handleInitial(m, kind, binding, key)
|
||||
case StateOperatorPending, StateMotionCount:
|
||||
return h.handleAfterOperator(m, kind, binding, key)
|
||||
}
|
||||
h.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *InputHandler) handleInitial(m *model, kind string, binding any, key string) tea.Cmd {
|
||||
count := h.effectiveCount()
|
||||
|
||||
switch kind {
|
||||
case "motion":
|
||||
motion := binding.(Motion)
|
||||
if r, ok := motion.(Repeatable); ok {
|
||||
motion = r.WithCount(count).(Motion)
|
||||
}
|
||||
cmd := motion.Execute(m)
|
||||
h.Reset()
|
||||
return cmd
|
||||
|
||||
case "operator":
|
||||
h.operator = binding.(Operator)
|
||||
h.operatorKey = key
|
||||
h.state = StateOperatorPending
|
||||
return nil
|
||||
|
||||
case "action":
|
||||
action := binding.(Action)
|
||||
if r, ok := action.(Repeatable); ok {
|
||||
action = r.WithCount(count)
|
||||
}
|
||||
cmd := action.Execute(m)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
|
||||
h.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *InputHandler) handleAfterOperator(m *model, kind string, binding any, key string) tea.Cmd {
|
||||
count := h.effectiveCount()
|
||||
|
||||
// dd, yy, cc - same operator key pressed twice
|
||||
if kind == "operator" && key == h.operatorKey {
|
||||
cmd := h.operator.DoublePress(m)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Motion after operator
|
||||
if kind == "motion" {
|
||||
motion := binding.(Motion)
|
||||
if r, ok := motion.(Repeatable); ok {
|
||||
motion = r.WithCount(count).(Motion)
|
||||
}
|
||||
// Get range here
|
||||
start := m.getCursorPosition()
|
||||
motion.Execute(m)
|
||||
end := m.getCursorPosition()
|
||||
cmd := h.operator.Operate(m, start, end)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
|
||||
h.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *InputHandler) tryAccumulateCount(key string) bool {
|
||||
if len(key) != 1 || key[0] < '0' || key[0] > '9' {
|
||||
return false
|
||||
}
|
||||
|
||||
digit := int(key[0] - '0')
|
||||
|
||||
// 0 at start is a motion, not a count
|
||||
if digit == 0 && h.currentCount() == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch h.state {
|
||||
case StateReady, StateCount:
|
||||
h.count1 = h.count1*10 + digit
|
||||
h.state = StateCount
|
||||
case StateOperatorPending, StateMotionCount:
|
||||
h.count2 = h.count2*10 + digit
|
||||
h.state = StateMotionCount
|
||||
}
|
||||
|
||||
h.buffer += key
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *InputHandler) currentCount() int {
|
||||
if h.state == StateOperatorPending || h.state == StateMotionCount {
|
||||
return h.count2
|
||||
}
|
||||
return h.count1
|
||||
}
|
||||
|
||||
func (h *InputHandler) effectiveCount() int {
|
||||
c1, c2 := h.count1, h.count2
|
||||
if c1 == 0 {
|
||||
c1 = 1
|
||||
}
|
||||
if c2 == 0 {
|
||||
c2 = 1
|
||||
}
|
||||
return c1 * c2
|
||||
}
|
||||
|
||||
func (h *InputHandler) Reset() {
|
||||
h.state = StateReady
|
||||
h.count1 = 0
|
||||
h.count2 = 0
|
||||
h.operator = nil
|
||||
h.operatorKey = ""
|
||||
h.buffer = ""
|
||||
h.pending = ""
|
||||
}
|
||||
|
||||
func (h *InputHandler) Pending() string {
|
||||
return h.buffer
|
||||
}
|
||||
76
keymap.go
Normal file
76
keymap.go
Normal file
@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
type Keymap struct {
|
||||
motions map[string]Motion
|
||||
operators map[string]Operator
|
||||
actions map[string]Action // standalone actions: i.e., 'i', 'a'
|
||||
}
|
||||
|
||||
func NewNormalKeymap() *Keymap {
|
||||
return &Keymap{
|
||||
motions: map[string]Motion{
|
||||
"j": MoveDown{1},
|
||||
"k": MoveUp{1},
|
||||
"h": MoveLeft{1},
|
||||
"l": MoveRight{1},
|
||||
"G": MoveToBottom{},
|
||||
"gg": MoveToTop{}, // multi-key example
|
||||
// "w": MoveWord{1},
|
||||
// "b": MoveWordBack{1},
|
||||
"0": MoveToLineStart{},
|
||||
"$": MoveToLineEnd{},
|
||||
},
|
||||
operators: map[string]Operator{
|
||||
// "d": DeleteOp{},
|
||||
// "c": ChangeOp{},
|
||||
// "y": YankOp{},
|
||||
},
|
||||
actions: map[string]Action{
|
||||
"i": EnterInsert{},
|
||||
"a": EnterInsertAfter{},
|
||||
"I": EnterInsertLineStart{},
|
||||
"A": EnterInsertLineEnd{},
|
||||
"o": OpenLineBelow{},
|
||||
"O": OpenLineAbove{},
|
||||
"x": DeleteChar{1},
|
||||
// "p": Paste{},
|
||||
// "u": Undo{},
|
||||
// "ctrl+r": Redo{},
|
||||
"ctrl+c": Quit{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns what type of binding a key is
|
||||
func (km *Keymap) Lookup(key string) (kind string, value any) {
|
||||
if m, ok := km.motions[key]; ok {
|
||||
return "motion", m
|
||||
}
|
||||
if o, ok := km.operators[key]; ok {
|
||||
return "operator", o
|
||||
}
|
||||
if a, ok := km.actions[key]; ok {
|
||||
return "action", a
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// HasPrefix returns true if any binding starts with this prefix
|
||||
func (km *Keymap) HasPrefix(prefix string) bool {
|
||||
for key := range km.motions {
|
||||
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for key := range km.operators {
|
||||
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for key := range km.actions {
|
||||
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
10
main.go
Normal file
10
main.go
Normal file
@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
func main() {
|
||||
tea.NewProgram(
|
||||
newModel(),
|
||||
tea.WithAltScreen(),
|
||||
).Run()
|
||||
}
|
||||
65
model.go
Normal file
65
model.go
Normal file
@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type mode int
|
||||
|
||||
const (
|
||||
NormalMode mode = iota
|
||||
InsertMode
|
||||
CommandMode
|
||||
)
|
||||
|
||||
type cursor struct {
|
||||
x int
|
||||
y int
|
||||
}
|
||||
|
||||
type model struct {
|
||||
lines []string
|
||||
cursor cursor
|
||||
s_gutter int
|
||||
mode mode
|
||||
win_h int
|
||||
win_w int
|
||||
command string
|
||||
input *InputHandler
|
||||
}
|
||||
|
||||
func newModel() model {
|
||||
return model{
|
||||
lines: []string{
|
||||
"Hello world",
|
||||
"line 2",
|
||||
"line 3",
|
||||
"line 4",
|
||||
"line 5",
|
||||
"line 6",
|
||||
},
|
||||
cursor: cursor{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
s_gutter: 5,
|
||||
mode: NormalMode,
|
||||
command: "",
|
||||
input: NewInputHandler(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *model) clampCursorX() {
|
||||
lineLen := len(m.lines[m.cursor.y])
|
||||
if lineLen == 0 {
|
||||
m.cursor.x = 0
|
||||
} else if m.cursor.x >= lineLen {
|
||||
m.cursor.x = lineLen
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) getCursorPosition() Position {
|
||||
return Position{Line: m.cursor.y, Col: m.cursor.x}
|
||||
}
|
||||
198
motion.go
Normal file
198
motion.go
Normal file
@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
// MoveDown implements Motion
|
||||
type MoveDown struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (a MoveDown) Execute(m *model) tea.Cmd {
|
||||
for i := 0; i < a.count && m.cursor.y < len(m.lines)-1; i++ {
|
||||
m.cursor.y++
|
||||
}
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveDown) WithCount(n int) Action {
|
||||
return MoveDown{count: n}
|
||||
}
|
||||
|
||||
// MoveUp implements Motion
|
||||
type MoveUp struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (a MoveUp) Execute(m *model) tea.Cmd {
|
||||
for i := 0; i < a.count && m.cursor.y > 0; i++ {
|
||||
m.cursor.y--
|
||||
}
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveUp) WithCount(n int) Action {
|
||||
return MoveUp{count: n}
|
||||
}
|
||||
|
||||
type MoveLeft struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (a MoveLeft) Execute(m *model) tea.Cmd {
|
||||
for i := 0; i < a.count && m.cursor.x > 0; i++ {
|
||||
m.cursor.x--
|
||||
}
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveLeft) WithCount(n int) Action {
|
||||
return MoveLeft{count: n}
|
||||
}
|
||||
|
||||
type MoveRight struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (a MoveRight) Execute(m *model) tea.Cmd {
|
||||
lineLen := len(m.lines[m.cursor.y])
|
||||
for i := 0; i < a.count && m.cursor.x <= lineLen; i++ {
|
||||
m.cursor.x++
|
||||
}
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MoveRight) WithCount(n int) Action {
|
||||
return MoveRight{count: n}
|
||||
}
|
||||
|
||||
type EnterInsert struct{}
|
||||
|
||||
func (a EnterInsert) Execute(m *model) tea.Cmd {
|
||||
m.mode = InsertMode
|
||||
return nil
|
||||
}
|
||||
|
||||
type EnterInsertAfter struct{}
|
||||
|
||||
func (a EnterInsertAfter) Execute(m *model) tea.Cmd {
|
||||
m.cursor.x++
|
||||
m.clampCursorX()
|
||||
m.mode = InsertMode
|
||||
return nil
|
||||
}
|
||||
|
||||
type Quit struct{}
|
||||
|
||||
func (a Quit) Execute(m *model) tea.Cmd {
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
// MoveToTop implements Motion (gg)
|
||||
type MoveToTop struct{}
|
||||
|
||||
func (a MoveToTop) Execute(m *model) tea.Cmd {
|
||||
m.cursor.y = 0
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveToBottom implements Motion (G)
|
||||
type MoveToBottom struct{}
|
||||
|
||||
func (a MoveToBottom) Execute(m *model) tea.Cmd {
|
||||
m.cursor.y = len(m.lines) - 1
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
type EnterInsertLineStart struct{}
|
||||
|
||||
func (a EnterInsertLineStart) Execute(m *model) tea.Cmd {
|
||||
m.cursor.x = 0
|
||||
m.clampCursorX()
|
||||
m.mode = InsertMode
|
||||
return nil
|
||||
}
|
||||
|
||||
type EnterInsertLineEnd struct{}
|
||||
|
||||
func (a EnterInsertLineEnd) Execute(m *model) tea.Cmd {
|
||||
m.cursor.x = len(m.lines[m.cursor.y])
|
||||
m.clampCursorX()
|
||||
m.mode = InsertMode
|
||||
return nil
|
||||
}
|
||||
|
||||
type MoveToLineStart struct{}
|
||||
|
||||
func (a MoveToLineStart) Execute(m *model) tea.Cmd {
|
||||
m.cursor.x = 0
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
type MoveToLineEnd struct{}
|
||||
|
||||
func (a MoveToLineEnd) Execute(m *model) tea.Cmd {
|
||||
m.cursor.x = len(m.lines[m.cursor.y])
|
||||
m.clampCursorX()
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Count
|
||||
type OpenLineBelow struct{}
|
||||
|
||||
func (a OpenLineBelow) Execute(m *model) tea.Cmd {
|
||||
pos := m.cursor.y
|
||||
|
||||
if pos >= len(m.lines) {
|
||||
m.lines = append(m.lines, "")
|
||||
} else {
|
||||
m.lines = append(m.lines[:pos+1], append([]string{""}, m.lines[pos+1:]...)...)
|
||||
}
|
||||
|
||||
m.cursor.y++
|
||||
m.clampCursorX()
|
||||
m.mode = InsertMode
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Count
|
||||
type OpenLineAbove struct{}
|
||||
|
||||
func (a OpenLineAbove) Execute(m *model) tea.Cmd {
|
||||
pos := m.cursor.y
|
||||
|
||||
if pos <= 0 {
|
||||
m.lines = append([]string{""}, m.lines...)
|
||||
} else {
|
||||
m.lines = append(m.lines[:pos], append([]string{""}, m.lines[pos:]...)...)
|
||||
}
|
||||
|
||||
m.clampCursorX()
|
||||
m.mode = InsertMode
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Visual mode
|
||||
type DeleteChar struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (a DeleteChar) Execute(m *model) tea.Cmd {
|
||||
pos := m.cursor.x
|
||||
for i := 0; i < a.count && m.cursor.x < len(m.lines[m.cursor.y]); i++ {
|
||||
line := m.lines[m.cursor.y]
|
||||
m.lines[m.cursor.y] = line[:pos] + line[pos+1:]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a DeleteChar) WithCount(n int) Action {
|
||||
return DeleteChar{count: n}
|
||||
}
|
||||
34
style.go
Normal file
34
style.go
Normal file
@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
func (m model) cursorStyle() lipgloss.Style {
|
||||
switch m.mode {
|
||||
case NormalMode:
|
||||
// Block cursor for normal mode
|
||||
return lipgloss.NewStyle().Reverse(true)
|
||||
case InsertMode:
|
||||
// Bar/underline for insert mode
|
||||
return lipgloss.NewStyle().Underline(true)
|
||||
case CommandMode:
|
||||
return lipgloss.NewStyle()
|
||||
// case VisualMode:
|
||||
// // Colored block for visual mode
|
||||
// return lipgloss.NewStyle().
|
||||
// Background(lipgloss.Color("62")).
|
||||
// Foreground(lipgloss.Color("230"))
|
||||
default:
|
||||
return lipgloss.NewStyle().Reverse(true)
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) gutterStyle(currentLine bool) lipgloss.Style {
|
||||
fg := lipgloss.Color("243")
|
||||
if currentLine {
|
||||
fg = lipgloss.Color("#d69d00")
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Width(m.s_gutter).
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(fg)
|
||||
}
|
||||
117
update.go
Normal file
117
update.go
Normal file
@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.win_h = msg.Height
|
||||
m.win_w = msg.Width
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch m.mode {
|
||||
case NormalMode:
|
||||
return m, m.input.Handle(&m, msg.String())
|
||||
|
||||
case InsertMode:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "ctrl+d":
|
||||
return m, tea.Quit
|
||||
|
||||
case "esc":
|
||||
// Allow i to step back, but a to stay put
|
||||
if m.cursor.x > 0 {
|
||||
m.cursor.x--
|
||||
}
|
||||
m.mode = NormalMode
|
||||
|
||||
case "enter":
|
||||
y := m.cursor.y
|
||||
x := m.cursor.x
|
||||
|
||||
// Simple case, at end, just create a line
|
||||
if x == len(m.lines[y]) {
|
||||
m.lines = append(m.lines[:y+1], append([]string{""}, m.lines[y+1:]...)...)
|
||||
|
||||
// otherwise, splice
|
||||
} else {
|
||||
l := m.lines[y]
|
||||
m.lines[y] = l[:x]
|
||||
m.lines = append(m.lines[:y+1], append([]string{l[x:]}, m.lines[y+1:]...)...)
|
||||
}
|
||||
|
||||
m.cursor.y++
|
||||
m.cursor.x = 0
|
||||
|
||||
case "backspace":
|
||||
x := m.cursor.x
|
||||
y := m.cursor.y
|
||||
l := m.lines[y]
|
||||
if m.cursor.x > 0 {
|
||||
m.lines[y] = l[:x-1] + l[x:]
|
||||
m.cursor.x--
|
||||
} else if m.cursor.y > 0 {
|
||||
new_x := len(m.lines[y-1])
|
||||
m.lines[y-1] = m.lines[y-1] + l
|
||||
|
||||
m.lines = append(m.lines[:y], m.lines[y+1:]...)
|
||||
|
||||
m.cursor.y--
|
||||
m.cursor.x = new_x
|
||||
}
|
||||
|
||||
case "left":
|
||||
if m.cursor.x > 0 {
|
||||
m.cursor.x--
|
||||
}
|
||||
|
||||
case "right":
|
||||
if m.cursor.x < len(m.lines[m.cursor.y]) {
|
||||
m.cursor.x++
|
||||
}
|
||||
|
||||
case "up":
|
||||
if m.cursor.y > 0 {
|
||||
m.cursor.y--
|
||||
}
|
||||
|
||||
if m.cursor.x > len(m.lines[m.cursor.y])-1 {
|
||||
m.cursor.x = len(m.lines[m.cursor.y])
|
||||
}
|
||||
|
||||
case "down":
|
||||
if m.cursor.y < len(m.lines)-1 {
|
||||
m.cursor.y++
|
||||
}
|
||||
|
||||
if m.cursor.x > len(m.lines[m.cursor.y])-1 {
|
||||
m.cursor.x = len(m.lines[m.cursor.y])
|
||||
}
|
||||
|
||||
default:
|
||||
x := m.cursor.x
|
||||
y := m.cursor.y
|
||||
l := m.lines[y]
|
||||
ch := msg.String()
|
||||
if x < len(l) {
|
||||
m.lines[y] = l[:x] + ch + l[x:]
|
||||
} else {
|
||||
m.lines[y] = l + ch
|
||||
}
|
||||
m.cursor.x += len(ch)
|
||||
}
|
||||
case CommandMode:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.mode = NormalMode
|
||||
m.command = ""
|
||||
|
||||
default:
|
||||
m.command += msg.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
80
view.go
Normal file
80
view.go
Normal file
@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m model) View() string {
|
||||
var view strings.Builder
|
||||
|
||||
for y := 0; y < m.win_h-1; y++ {
|
||||
|
||||
if y < len(m.lines) {
|
||||
|
||||
var (
|
||||
gutter string
|
||||
currentLine bool = false
|
||||
lineNumber int
|
||||
)
|
||||
if y > m.cursor.y {
|
||||
lineNumber = y - m.cursor.y
|
||||
gutter = fmt.Sprintf("%*d ", m.s_gutter-1, lineNumber)
|
||||
} else if y < m.cursor.y {
|
||||
lineNumber = m.cursor.y - y
|
||||
gutter = fmt.Sprintf("%*d ", m.s_gutter-1, lineNumber)
|
||||
} else {
|
||||
lineNumber = y + 1
|
||||
currentLine = true
|
||||
if lineNumber < 100 {
|
||||
gutter = fmt.Sprintf("%*d ", m.s_gutter-2, lineNumber)
|
||||
} else {
|
||||
gutter = fmt.Sprintf("%*d ", m.s_gutter-1, lineNumber)
|
||||
}
|
||||
}
|
||||
view.WriteString(m.gutterStyle(currentLine).Render(gutter))
|
||||
|
||||
// TODO: Do we need to do offset calculation?
|
||||
|
||||
runes := []rune(m.lines[y])
|
||||
for x := 0; x <= len(runes); x++ {
|
||||
if m.cursor.y == y && m.cursor.x == x {
|
||||
if x < len(runes) {
|
||||
view.WriteString(m.cursorStyle().Render(string(runes[x])))
|
||||
} else {
|
||||
view.WriteString(m.cursorStyle().Render(" "))
|
||||
}
|
||||
} else if x < len(runes) {
|
||||
view.WriteRune(runes[x])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
format := fmt.Sprintf("%%-%ds ", m.s_gutter-1)
|
||||
fmt.Fprintf(&view, format, "~")
|
||||
}
|
||||
|
||||
view.WriteString("\n")
|
||||
}
|
||||
|
||||
// Draw status bar
|
||||
var modeString string
|
||||
switch m.mode {
|
||||
case NormalMode:
|
||||
modeString = "NORMAL"
|
||||
case InsertMode:
|
||||
modeString = "INSERT"
|
||||
case CommandMode:
|
||||
modeString = "COMMAND"
|
||||
}
|
||||
|
||||
var bar string
|
||||
if m.mode == CommandMode {
|
||||
bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) %s | %s", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.command, m.input.buffer)
|
||||
} else {
|
||||
bar = fmt.Sprintf(" %6s | %d:%d (%d:%d) | %s", modeString, m.cursor.x, m.cursor.y, m.win_w, m.win_h, m.input.buffer)
|
||||
}
|
||||
|
||||
view.WriteString(bar)
|
||||
|
||||
return view.String()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user