commit 6c0c289b5235f04721a40127ff53fe14bf266c9f Author: Hayden Hargreaves Date: Sun Feb 8 23:05:59 2026 -0700 Initial commit 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() +}