Initial commit

This commit is contained in:
Hayden Hargreaves 2026-02-08 23:05:59 -07:00
commit 6c0c289b52
13 changed files with 1004 additions and 0 deletions

29
actions.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}