From 6c0c289b5235f04721a40127ff53fe14bf266c9f Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Sun, 8 Feb 2026 23:05:59 -0700 Subject: [PATCH] Initial commit --- actions.go | 29 ++++++++ flake.lock | 61 ++++++++++++++++ flake.nix | 50 +++++++++++++ go.mod | 31 ++++++++ go.sum | 48 +++++++++++++ input.go | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++ keymap.go | 76 ++++++++++++++++++++ main.go | 10 +++ model.go | 65 +++++++++++++++++ motion.go | 198 +++++++++++++++++++++++++++++++++++++++++++++++++++ style.go | 34 +++++++++ update.go | 117 ++++++++++++++++++++++++++++++ view.go | 80 +++++++++++++++++++++ 13 files changed, 1004 insertions(+) create mode 100644 actions.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 input.go create mode 100644 keymap.go create mode 100644 main.go create mode 100644 model.go create mode 100644 motion.go create mode 100644 style.go create mode 100644 update.go create mode 100644 view.go diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..3e0775f --- /dev/null +++ b/actions.go @@ -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 +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c759433 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ea4868c --- /dev/null +++ b/flake.nix @@ -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 + ''; + }; + } + ); +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..651ff78 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3cb62b3 --- /dev/null +++ b/go.sum @@ -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= diff --git a/input.go b/input.go new file mode 100644 index 0000000..229d089 --- /dev/null +++ b/input.go @@ -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 +} diff --git a/keymap.go b/keymap.go new file mode 100644 index 0000000..1dcc2f6 --- /dev/null +++ b/keymap.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..de2a58c --- /dev/null +++ b/main.go @@ -0,0 +1,10 @@ +package main + +import tea "github.com/charmbracelet/bubbletea" + +func main() { + tea.NewProgram( + newModel(), + tea.WithAltScreen(), + ).Run() +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..4e4b3c2 --- /dev/null +++ b/model.go @@ -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} +} diff --git a/motion.go b/motion.go new file mode 100644 index 0000000..1bc2f41 --- /dev/null +++ b/motion.go @@ -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} +} diff --git a/style.go b/style.go new file mode 100644 index 0000000..1106857 --- /dev/null +++ b/style.go @@ -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) +} diff --git a/update.go b/update.go new file mode 100644 index 0000000..3a67c4e --- /dev/null +++ b/update.go @@ -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 +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..f392732 --- /dev/null +++ b/view.go @@ -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() +}