From ee7bf9354b5ff6bc6dbe68d7154037a63eb0c4d0 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Fri, 13 Feb 2026 23:16:47 -0700 Subject: [PATCH] feat: rough command mode implementation I am starting to develop so fast, testing is such a life saver, oh my god. --- internal/action/action.go | 5 + internal/action/command.go | 29 +- internal/action/misc.go | 2 + internal/command/handlers.go | 222 +++++++++++++ internal/command/registry.go | 142 ++++++++ internal/command/registry_test.go | 235 ++++++++++++++ internal/editor/integration_command_test.go | 341 ++++++++++++++++++++ internal/editor/model.go | 22 ++ internal/editor/style.go | 5 + internal/editor/update.go | 26 -- internal/editor/view.go | 42 ++- internal/input/keymap.go | 3 +- 12 files changed, 1027 insertions(+), 47 deletions(-) create mode 100644 internal/command/handlers.go create mode 100644 internal/command/registry.go create mode 100644 internal/command/registry_test.go create mode 100644 internal/editor/integration_command_test.go diff --git a/internal/action/action.go b/internal/action/action.go index c3888d3..d39dff0 100644 --- a/internal/action/action.go +++ b/internal/action/action.go @@ -77,9 +77,14 @@ type Model interface { SetCommand(cmd string) CommandCursor() int SetCommandCursor(cur int) + CommandError() error + SetCommandError(err error) + CommandOutput() string + SetCommandOutput(out string) // Settings Settings() Settings + SetSettings(s Settings) // Mode Mode() Mode diff --git a/internal/action/command.go b/internal/action/command.go index d4089a8..7ce5fc8 100644 --- a/internal/action/command.go +++ b/internal/action/command.go @@ -9,6 +9,8 @@ type ExitCommandMode struct{} func (a ExitCommandMode) Execute(m Model) tea.Cmd { m.SetCommandCursor(0) m.SetCommand("") + m.SetCommandOutput("") + m.SetCommandError(nil) m.SetMode(NormalMode) return nil } @@ -99,14 +101,33 @@ func (a CommandDeletePreviousWord) Execute(m Model) tea.Cmd { return nil } -type CommandExecute struct{} +type CommandExecute struct { + Registry CommandRegistry +} + +// CommandRegistry interface for executing commands +type CommandRegistry interface { + Execute(m Model, cmdLine string) (tea.Cmd, error) +} func (a CommandExecute) Execute(m Model) tea.Cmd { - // TODO: Implement + cmdLine := m.Command() + // Clear command state and return to normal mode m.SetCommandCursor(0) - m.SetCommand("") + m.SetCommandError(nil) m.SetMode(NormalMode) - return nil + if a.Registry == nil || cmdLine == "" { + return nil + } + + cmd, err := a.Registry.Execute(m, cmdLine) + if err != nil { + // TODO: Display error message to user + m.SetCommandError(err) + return nil + } + + return cmd } diff --git a/internal/action/misc.go b/internal/action/misc.go index f21818a..414fae5 100644 --- a/internal/action/misc.go +++ b/internal/action/misc.go @@ -15,6 +15,8 @@ type EnterComandMode struct{} func (a EnterComandMode) Execute(m Model) tea.Cmd { m.SetMode(CommandMode) m.SetCommand("") + m.SetCommandOutput("") + m.SetCommandError(nil) m.SetCommandCursor(0) return nil } diff --git a/internal/command/handlers.go b/internal/command/handlers.go new file mode 100644 index 0000000..8afff45 --- /dev/null +++ b/internal/command/handlers.go @@ -0,0 +1,222 @@ +package command + +import ( + "fmt" + "strconv" + "strings" + + "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" +) + +// QuitMsg signals the application should quit +type QuitMsg struct{} + +// ErrorMsg signals an error to display +type ErrorMsg struct { + Err error +} + +// cmdQuit handles :quit / :q +func cmdQuit(m action.Model, args []string) tea.Cmd { + return func() tea.Msg { + return tea.Quit() + } +} + +// cmdQuitAll handles :qall / :qa +func cmdQuitAll(m action.Model, args []string) tea.Cmd { + return func() tea.Msg { + return tea.Quit() + } +} + +// cmdWrite handles :write / :w +func cmdWrite(m action.Model, args []string) tea.Cmd { + // TODO: Implement file saving + // If args provided, save to that filename + // Otherwise save to current file + return nil +} + +// cmdWriteAll handles :wall / :wa +func cmdWriteAll(m action.Model, args []string) tea.Cmd { + // TODO: Implement saving all buffers + return nil +} + +// cmdWriteQuit handles :wq +func cmdWriteQuit(m action.Model, args []string) tea.Cmd { + // TODO: Save then quit + return func() tea.Msg { + return tea.Quit() + } +} + +// cmdSet handles :set option[=value] +// Examples: +// +// :set number - enable number +// :set nonumber - disable number +// :set number! - toggle number +// :set tabstop=4 - set tabstop to 4 +// :set ts=4 - set tabstop to 4 (abbreviation) +func cmdSet(m action.Model, args []string) tea.Cmd { + if len(args) == 0 { + out := fmt.Sprintf("%+v", m.Settings()) + m.SetCommandOutput(out) + return nil + } + + for _, arg := range args { + if err := parseSetOption(m, arg); err != nil { + m.SetCommandError(err) + return nil + } + } + + return nil +} + +// Setting represents a configurable option +type Setting struct { + Name string + ShortForm string + Type SettingType + Get func(s action.Settings) any + Set func(m action.Model, val any) +} + +type SettingType int + +const ( + BoolSetting SettingType = iota + IntSetting + StringSetting +) + +// settingsMap defines all available settings +var settingsMap = []Setting{ + { + Name: "number", + ShortForm: "nu", + Type: BoolSetting, + Get: func(s action.Settings) any { return s.Number }, + Set: func(m action.Model, val any) { + s := m.Settings() + s.Number = val.(bool) + m.SetSettings(s) + }, + }, + { + Name: "relativenumber", + ShortForm: "rnu", + Type: BoolSetting, + Get: func(s action.Settings) any { return s.RelativeNumber }, + Set: func(m action.Model, val any) { + s := m.Settings() + s.RelativeNumber = val.(bool) + m.SetSettings(s) + }, + }, + { + Name: "tabstop", + ShortForm: "ts", + Type: IntSetting, + Get: func(s action.Settings) any { return s.TabSize }, + Set: func(m action.Model, val any) { + s := m.Settings() + s.TabSize = val.(int) + m.SetSettings(s) + }, + }, + { + Name: "scrolloff", + ShortForm: "so", + Type: IntSetting, + Get: func(s action.Settings) any { return s.ScrollOff }, + Set: func(m action.Model, val any) { + s := m.Settings() + s.ScrollOff = val.(int) + m.SetSettings(s) + }, + }, +} + +func lookupSetting(name string) *Setting { + for i := range settingsMap { + s := &settingsMap[i] + if name == s.Name || name == s.ShortForm { + return s + } + // Prefix matching + if len(name) >= len(s.ShortForm) && strings.HasPrefix(s.Name, name) { + return s + } + } + return nil +} + +func parseSetOption(m action.Model, opt string) error { + // Handle toggle: option! + if name, ok := strings.CutSuffix(opt, "!"); ok { + setting := lookupSetting(name) + if setting == nil { + return nil // Unknown setting + } + if setting.Type == BoolSetting { + // Toggle the boolean + currentVal := setting.Get(m.Settings()).(bool) + setting.Set(m, !currentVal) + } + return nil + } + + // Handle disable: nooption + if name, ok := strings.CutPrefix(opt, "no"); ok { + setting := lookupSetting(name) + if setting == nil { + return nil + } + if setting.Type == BoolSetting { + setting.Set(m, false) + } + return nil + } + + // Handle assignment: option=value + if strings.Contains(opt, "=") { + parts := strings.SplitN(opt, "=", 2) + name, value := parts[0], parts[1] + setting := lookupSetting(name) + if setting == nil { + return nil + } + switch setting.Type { + case IntSetting: + intVal, err := strconv.Atoi(value) + if err != nil { + return err + } + setting.Set(m, intVal) + case StringSetting: + setting.Set(m, value) + case BoolSetting: + // Handle :set option=true / :set option=false + boolVal := value == "true" || value == "1" || value == "yes" + setting.Set(m, boolVal) + } + return nil + } + + // Handle enable: option (boolean only) + setting := lookupSetting(opt) + if setting == nil { + return nil + } + if setting.Type == BoolSetting { + setting.Set(m, true) + } + + return nil +} diff --git a/internal/command/registry.go b/internal/command/registry.go new file mode 100644 index 0000000..057ef15 --- /dev/null +++ b/internal/command/registry.go @@ -0,0 +1,142 @@ +package command + +import ( + "fmt" + "strings" + + "git.gophernest.net/azpect/TextEditor/internal/action" + tea "github.com/charmbracelet/bubbletea" +) + +// Command represents a command that can be executed from command mode +type Command struct { + Name string // Full name: "quit" + ShortForm string // Minimum abbreviation: "q" + Handler func(m action.Model, args []string) tea.Cmd // Handler function +} + +// Registry holds all registered commands +type Registry struct { + commands []Command +} + +// NewRegistry creates a new command registry with default commands +func NewRegistry() *Registry { + r := &Registry{} + r.registerDefaults() + return r +} + +// Register adds a command to the registry +func (r *Registry) Register(cmd Command) { + r.commands = append(r.commands, cmd) +} + +// Lookup finds a command by name or abbreviation +// Returns the command and any error (unknown or ambiguous) +func (r *Registry) Lookup(input string) (*Command, error) { + if input == "" { + return nil, fmt.Errorf("no command given") + } + + var matches []*Command + + for i := range r.commands { + cmd := &r.commands[i] + + // Exact match on short form + if input == cmd.ShortForm { + return cmd, nil + } + + // Exact match on full name + if input == cmd.Name { + return cmd, nil + } + + // Prefix match: input must be at least as long as short form + // and must be a prefix of the full name + if len(input) >= len(cmd.ShortForm) && strings.HasPrefix(cmd.Name, input) { + matches = append(matches, cmd) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("unknown command: %s", input) + } + if len(matches) > 1 { + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.Name + } + return nil, fmt.Errorf("ambiguous command: %s (could be: %s)", input, strings.Join(names, ", ")) + } + + return matches[0], nil +} + +// Parse splits a command line into command name and arguments +func Parse(cmdLine string) (name string, args []string) { + parts := strings.Fields(cmdLine) + if len(parts) == 0 { + return "", nil + } + return parts[0], parts[1:] +} + +// Execute parses and executes a command line +func (r *Registry) Execute(m action.Model, cmdLine string) (tea.Cmd, error) { + name, args := Parse(cmdLine) + + cmd, err := r.Lookup(name) + if err != nil { + return nil, err + } + + return cmd.Handler(m, args), nil +} + +// DefaultRegistry is the global command registry +var DefaultRegistry = NewRegistry() + +// registerDefaults registers the built-in commands +func (r *Registry) registerDefaults() { + // Quit commands + r.Register(Command{ + Name: "quit", + ShortForm: "q", + Handler: cmdQuit, + }) + + r.Register(Command{ + Name: "qall", + ShortForm: "qa", + Handler: cmdQuitAll, + }) + + // Write commands + r.Register(Command{ + Name: "write", + ShortForm: "w", + Handler: cmdWrite, + }) + + r.Register(Command{ + Name: "wall", + ShortForm: "wa", + Handler: cmdWriteAll, + }) + + r.Register(Command{ + Name: "wq", + ShortForm: "wq", + Handler: cmdWriteQuit, + }) + + // Set command + r.Register(Command{ + Name: "set", + ShortForm: "se", + Handler: cmdSet, + }) +} diff --git a/internal/command/registry_test.go b/internal/command/registry_test.go new file mode 100644 index 0000000..8c043a0 --- /dev/null +++ b/internal/command/registry_test.go @@ -0,0 +1,235 @@ +package command + +import ( + "testing" +) + +// NOTE: AI Generated tests + +func TestRegistryLookup(t *testing.T) { + r := NewRegistry() + + t.Run("exact short form match", func(t *testing.T) { + cmd, err := r.Lookup("q") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "quit" { + t.Errorf("cmd.Name = %q, want \"quit\"", cmd.Name) + } + }) + + t.Run("exact full name match", func(t *testing.T) { + cmd, err := r.Lookup("quit") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "quit" { + t.Errorf("cmd.Name = %q, want \"quit\"", cmd.Name) + } + }) + + t.Run("prefix match", func(t *testing.T) { + cmd, err := r.Lookup("qui") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "quit" { + t.Errorf("cmd.Name = %q, want \"quit\"", cmd.Name) + } + }) + + t.Run("qa matches qall not quit", func(t *testing.T) { + cmd, err := r.Lookup("qa") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "qall" { + t.Errorf("cmd.Name = %q, want \"qall\"", cmd.Name) + } + }) + + t.Run("qal matches qall", func(t *testing.T) { + cmd, err := r.Lookup("qal") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "qall" { + t.Errorf("cmd.Name = %q, want \"qall\"", cmd.Name) + } + }) + + t.Run("w matches write", func(t *testing.T) { + cmd, err := r.Lookup("w") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "write" { + t.Errorf("cmd.Name = %q, want \"write\"", cmd.Name) + } + }) + + t.Run("wa matches wall", func(t *testing.T) { + cmd, err := r.Lookup("wa") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "wall" { + t.Errorf("cmd.Name = %q, want \"wall\"", cmd.Name) + } + }) + + t.Run("se matches set", func(t *testing.T) { + cmd, err := r.Lookup("se") + if err != nil { + t.Fatalf("Lookup error: %v", err) + } + if cmd.Name != "set" { + t.Errorf("cmd.Name = %q, want \"set\"", cmd.Name) + } + }) + + t.Run("unknown command returns error", func(t *testing.T) { + _, err := r.Lookup("xyz") + if err == nil { + t.Error("expected error for unknown command") + } + }) + + t.Run("empty command returns error", func(t *testing.T) { + _, err := r.Lookup("") + if err == nil { + t.Error("expected error for empty command") + } + }) +} + +func TestParse(t *testing.T) { + t.Run("command only", func(t *testing.T) { + name, args := Parse("quit") + if name != "quit" { + t.Errorf("name = %q, want \"quit\"", name) + } + if len(args) != 0 { + t.Errorf("len(args) = %d, want 0", len(args)) + } + }) + + t.Run("command with one arg", func(t *testing.T) { + name, args := Parse("set number") + if name != "set" { + t.Errorf("name = %q, want \"set\"", name) + } + if len(args) != 1 { + t.Errorf("len(args) = %d, want 1", len(args)) + } + if args[0] != "number" { + t.Errorf("args[0] = %q, want \"number\"", args[0]) + } + }) + + t.Run("command with multiple args", func(t *testing.T) { + name, args := Parse("set number tabstop=4") + if name != "set" { + t.Errorf("name = %q, want \"set\"", name) + } + if len(args) != 2 { + t.Errorf("len(args) = %d, want 2", len(args)) + } + if args[0] != "number" { + t.Errorf("args[0] = %q, want \"number\"", args[0]) + } + if args[1] != "tabstop=4" { + t.Errorf("args[1] = %q, want \"tabstop=4\"", args[1]) + } + }) + + t.Run("empty string", func(t *testing.T) { + name, args := Parse("") + if name != "" { + t.Errorf("name = %q, want \"\"", name) + } + if args != nil { + t.Errorf("args = %v, want nil", args) + } + }) + + t.Run("whitespace only", func(t *testing.T) { + name, args := Parse(" ") + if name != "" { + t.Errorf("name = %q, want \"\"", name) + } + if args != nil { + t.Errorf("args = %v, want nil", args) + } + }) +} + +func TestLookupSetting(t *testing.T) { + t.Run("exact name match", func(t *testing.T) { + s := lookupSetting("number") + if s == nil { + t.Fatal("expected setting, got nil") + } + if s.Name != "number" { + t.Errorf("s.Name = %q, want \"number\"", s.Name) + } + }) + + t.Run("short form match", func(t *testing.T) { + s := lookupSetting("nu") + if s == nil { + t.Fatal("expected setting, got nil") + } + if s.Name != "number" { + t.Errorf("s.Name = %q, want \"number\"", s.Name) + } + }) + + t.Run("prefix match", func(t *testing.T) { + s := lookupSetting("numb") + if s == nil { + t.Fatal("expected setting, got nil") + } + if s.Name != "number" { + t.Errorf("s.Name = %q, want \"number\"", s.Name) + } + }) + + t.Run("rnu matches relativenumber", func(t *testing.T) { + s := lookupSetting("rnu") + if s == nil { + t.Fatal("expected setting, got nil") + } + if s.Name != "relativenumber" { + t.Errorf("s.Name = %q, want \"relativenumber\"", s.Name) + } + }) + + t.Run("ts matches tabstop", func(t *testing.T) { + s := lookupSetting("ts") + if s == nil { + t.Fatal("expected setting, got nil") + } + if s.Name != "tabstop" { + t.Errorf("s.Name = %q, want \"tabstop\"", s.Name) + } + }) + + t.Run("so matches scrolloff", func(t *testing.T) { + s := lookupSetting("so") + if s == nil { + t.Fatal("expected setting, got nil") + } + if s.Name != "scrolloff" { + t.Errorf("s.Name = %q, want \"scrolloff\"", s.Name) + } + }) + + t.Run("unknown returns nil", func(t *testing.T) { + s := lookupSetting("xyz") + if s != nil { + t.Errorf("expected nil, got %v", s) + } + }) +} diff --git a/internal/editor/integration_command_test.go b/internal/editor/integration_command_test.go new file mode 100644 index 0000000..68e89ca --- /dev/null +++ b/internal/editor/integration_command_test.go @@ -0,0 +1,341 @@ +package editor + +import ( + "testing" + + "git.gophernest.net/azpect/TextEditor/internal/action" +) + +// NOTE: AI Generated tests + +// Default settings are: Number=true, RelativeNumber=true, TabSize=2, ScrollOff=8 + +func TestCommandSetBoolean(t *testing.T) { + t.Run("':set nonumber' disables line numbers", func(t *testing.T) { + // Default has Number=true + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "n", "u", "m", "b", "e", "r", "enter") + + m := getFinalModel(t, tm) + if m.Settings().Number { + t.Error("expected Number to be false after :set nonumber") + } + }) + + t.Run("':set number' enables after disable", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + // First disable, then enable + sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "n", "u", "enter") + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter") + + m := getFinalModel(t, tm) + if !m.Settings().Number { + t.Error("expected Number to be true after :set nu") + } + }) + + t.Run("':set number!' toggles number off", func(t *testing.T) { + // Default has Number=true + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "m", "b", "e", "r", "!", "enter") + + m := getFinalModel(t, tm) + if m.Settings().Number { + t.Error("expected Number to be false after :set number!") + } + }) + + t.Run("':set number!' toggles back on", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + // Toggle off then on + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "!", "enter") + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "!", "enter") + + m := getFinalModel(t, tm) + if !m.Settings().Number { + t.Error("expected Number to be true after double toggle") + } + }) + + t.Run("':set nornu' disables relative numbers", func(t *testing.T) { + // Default has RelativeNumber=true + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "r", "n", "u", "enter") + + m := getFinalModel(t, tm) + if m.Settings().RelativeNumber { + t.Error("expected RelativeNumber to be false after :set nornu") + } + }) + + t.Run("':set rnu' enables after disable", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + // Disable then enable + sendKeys(tm, ":", "s", "e", "t", " ", "n", "o", "r", "n", "u", "enter") + sendKeys(tm, ":", "s", "e", "t", " ", "r", "n", "u", "enter") + + m := getFinalModel(t, tm) + if !m.Settings().RelativeNumber { + t.Error("expected RelativeNumber to be true after :set rnu") + } + }) +} + +func TestCommandSetInteger(t *testing.T) { + t.Run("':set tabstop=4' sets tab size", func(t *testing.T) { + // Default TabSize=2 + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "t", "a", "b", "s", "t", "o", "p", "=", "4", "enter") + + m := getFinalModel(t, tm) + if m.Settings().TabSize != 4 { + t.Errorf("TabSize = %d, want 4", m.Settings().TabSize) + } + }) + + t.Run("':set ts=8' uses short form", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "t", "s", "=", "8", "enter") + + m := getFinalModel(t, tm) + if m.Settings().TabSize != 8 { + t.Errorf("TabSize = %d, want 8", m.Settings().TabSize) + } + }) + + t.Run("':set scrolloff=5' sets scroll offset", func(t *testing.T) { + // Default ScrollOff=8 + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "s", "c", "r", "o", "l", "l", "o", "f", "f", "=", "5", "enter") + + m := getFinalModel(t, tm) + if m.Settings().ScrollOff != 5 { + t.Errorf("ScrollOff = %d, want 5", m.Settings().ScrollOff) + } + }) + + t.Run("':set so=10' uses short form", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + sendKeys(tm, ":", "s", "e", "t", " ", "s", "o", "=", "1", "0", "enter") + + m := getFinalModel(t, tm) + if m.Settings().ScrollOff != 10 { + t.Errorf("ScrollOff = %d, want 10", m.Settings().ScrollOff) + } + }) +} + +func TestCommandModeNavigation(t *testing.T) { + t.Run("':' enters command mode and esc exits", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "esc") + + m := getFinalModel(t, tm) + // After esc we should be back in normal mode + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode after esc", m.Mode()) + } + }) + + t.Run("'esc' exits command mode", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "esc") + + m := getFinalModel(t, tm) + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) + + t.Run("'enter' executes and exits command mode", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter") + + m := getFinalModel(t, tm) + if m.Mode() != action.NormalMode { + t.Errorf("Mode() = %v, want NormalMode", m.Mode()) + } + }) + + t.Run("command buffer is not cleared after execution", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "t", "e", "s", "t", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "test" { + t.Errorf("Command() = %q, want \"test\"", m.Command()) + } + }) +} + +func TestCommandModeEditing(t *testing.T) { + t.Run("backspace deletes character", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + // Type abc, backspace, then enter to execute and check result + sendKeys(tm, ":", "a", "b", "c", "backspace", "enter") + + m := getFinalModel(t, tm) + // Command buffer preserves last command after execution + if m.Command() != "ab" { + t.Errorf("Command() = %q, want \"ab\"", m.Command()) + } + }) + + t.Run("backspace at start does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "backspace", "a", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "a" { + t.Errorf("Command() = %q, want \"a\"", m.Command()) + } + }) + + t.Run("multiple backspaces", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "a", "b", "c", "backspace", "backspace", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "a" { + t.Errorf("Command() = %q, want \"a\"", m.Command()) + } + }) + + t.Run("delete key removes character at cursor", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + // Type abc, move left, delete 'c', then enter + sendKeys(tm, ":", "a", "b", "c", "left", "delete", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "ab" { + t.Errorf("Command() = %q, want \"ab\"", m.Command()) + } + }) + + t.Run("delete at end acts as backspace", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "a", "b", "c", "delete", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "ab" { + t.Errorf("Command() = %q, want \"ab\"", m.Command()) + } + }) + + t.Run("ctrl+w deletes previous word", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "m", "b", "e", "r", "ctrl+w", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "set " { + t.Errorf("Command() = %q, want \"set \"", m.Command()) + } + }) + + t.Run("ctrl+w deletes word and trailing space", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "o", "n", "e", " ", "t", "w", "o", " ", "ctrl+w", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "one " { + t.Errorf("Command() = %q, want \"one \"", m.Command()) + } + }) + + t.Run("ctrl+w at start does nothing", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "ctrl+w", "a", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "a" { + t.Errorf("Command() = %q, want \"a\"", m.Command()) + } + }) + + t.Run("insert at cursor position", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + // Type 'ac', move left, insert 'b' -> 'abc' + sendKeys(tm, ":", "a", "c", "left", "b", "enter") + + m := getFinalModel(t, tm) + if m.Command() != "abc" { + t.Errorf("Command() = %q, want \"abc\"", m.Command()) + } + }) +} + +func TestCommandModeErrors(t *testing.T) { + t.Run("unknown command sets error", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + sendKeys(tm, ":", "u", "n", "k", "n", "o", "w", "n", "c", "m", "d", "enter") + + m := getFinalModel(t, tm) + if m.commandError == nil { + t.Error("expected commandError to be set for unknown command") + } + }) + + t.Run("valid command clears error", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + // First cause an error + sendKeys(tm, ":", "b", "a", "d", "enter") + // Then run a valid command + sendKeys(tm, ":", "s", "e", "t", " ", "n", "u", "enter") + + m := getFinalModel(t, tm) + if m.commandError != nil { + t.Errorf("expected commandError to be nil, got %v", m.commandError) + } + }) + + t.Run("esc clears error", func(t *testing.T) { + lines := []string{"hello"} + tm := newTestModelWithLines(t, lines) + + // Cause an error + sendKeys(tm, ":", "b", "a", "d", "enter") + // Then escape + sendKeys(tm, ":", "esc") + + m := getFinalModel(t, tm) + if m.commandError != nil { + t.Errorf("expected commandError to be nil after esc, got %v", m.commandError) + } + }) +} diff --git a/internal/editor/model.go b/internal/editor/model.go index c42da80..aa0a112 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -31,6 +31,8 @@ type Model struct { // Command mode command string commandCursor int + commandError error + commandOutput string // Settings settings action.Settings @@ -159,11 +161,31 @@ func (m *Model) SetCommandCursor(cur int) { } } +func (m *Model) CommandError() error { + return m.commandError +} + +func (m *Model) SetCommandError(err error) { + m.commandError = err +} + +func (m *Model) CommandOutput() string { + return m.commandOutput +} + +func (m *Model) SetCommandOutput(out string) { + m.commandOutput = out +} + // Settings func (m *Model) Settings() action.Settings { return m.settings } +func (m *Model) SetSettings(s action.Settings) { + m.settings = s +} + // Window func (m *Model) ScrollY() int { return m.scrollY diff --git a/internal/editor/style.go b/internal/editor/style.go index 01ffe91..4cf497a 100644 --- a/internal/editor/style.go +++ b/internal/editor/style.go @@ -45,3 +45,8 @@ func (m Model) visualHighlightStyle() lipgloss.Style { bg := lipgloss.Color("#7a6a00") return lipgloss.NewStyle().Background(bg) } + +func (m Model) commandErrorStyle() lipgloss.Style { + fg := lipgloss.Color("#e3203a") + return lipgloss.NewStyle().Foreground(fg) +} diff --git a/internal/editor/update.go b/internal/editor/update.go index aef1eeb..618e192 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -14,33 +14,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.win_w = msg.Width case tea.KeyMsg: - // BUG: for use in debugging, until we have command mode - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - cmd = m.input.Handle(&m, msg.String()) - - // switch m.mode { - // case action.NormalMode, - // action.InsertMode, - // action.VisualMode, - // action.VisualBlockMode, - // action.VisualLineMode: - // cmd = m.input.Handle(&m, msg.String()) - // - // // The only one left to migrate! - // case action.CommandMode: - // switch msg.String() { - // case "esc": - // m.mode = action.NormalMode - // m.command = "" - // - // default: - // m.command += msg.String() - // m.SetCommandCursor(len(m.command)) - // } - // } } // Keep cursor in view after any update diff --git a/internal/editor/view.go b/internal/editor/view.go index 1e7824c..fd5ba7c 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -59,7 +59,7 @@ func posIsAnchor(m Model, col, line int) bool { func (m Model) View() string { var view strings.Builder - viewportHeight := m.win_h - 1 // -1 for status bar + viewportHeight := m.win_h - 2 // -2 for status bar and command bar start := m.ScrollY() end := m.ScrollY() + viewportHeight @@ -135,8 +135,9 @@ func (m Model) View() string { view.WriteString("\n") } - bar := drawStatusBar(m) - view.WriteString(bar) + view.WriteString(drawStatusBar(m)) + view.WriteString("\n") + view.WriteString(drawCommandBar(m)) return view.String() } @@ -156,7 +157,21 @@ func drawStatusBar(m Model) string { return left + middle + right } -func leftBar(m Model) (bar string) { +func leftBar(m Model) string { + return fmt.Sprintf(" %s", m.Mode().ToString()) +} + +func rightBar(m Model) (bar string) { + if m.Mode().IsVisualMode() { + lineCount := max(m.AnchorY(), m.CursorY()) - min(m.AnchorY(), m.CursorY()) + 1 + bar = fmt.Sprintf("%d:%d <%d>", m.CursorY(), m.CursorX(), lineCount) + } else { + bar = fmt.Sprintf("%d:%d ", m.CursorY(), m.CursorX()) + } + return +} + +func drawCommandBar(m Model) (bar string) { if m.Mode() == action.CommandMode { bar = ":" cmd := m.Command() @@ -173,18 +188,13 @@ func leftBar(m Model) (bar string) { bar += m.cursorStyle().Render(" ") } // bar = fmt.Sprintf("%s %d", bar, cur) - } else { - bar = fmt.Sprintf(" %s", m.Mode().ToString()) + } else if m.CommandError() != nil { + bar = m.commandErrorStyle().Render(m.CommandError().Error()) + } else if strings.TrimSpace(m.CommandOutput()) != "" { + bar = m.CommandOutput() + } else if strings.TrimSpace(m.Command()) != "" { + bar = fmt.Sprintf(":%s", m.Command()) } - return -} -func rightBar(m Model) (bar string) { - if m.Mode().IsVisualMode() { - lineCount := max(m.AnchorY(), m.CursorY()) - min(m.AnchorY(), m.CursorY()) + 1 - bar = fmt.Sprintf("%d:%d <%d>", m.CursorY(), m.CursorX(), lineCount) - } else { - bar = fmt.Sprintf("%d:%d ", m.CursorY(), m.CursorX()) - } - return + return bar } diff --git a/internal/input/keymap.go b/internal/input/keymap.go index 765d20e..8c44b26 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -2,6 +2,7 @@ package input import ( "git.gophernest.net/azpect/TextEditor/internal/action" + "git.gophernest.net/azpect/TextEditor/internal/command" "git.gophernest.net/azpect/TextEditor/internal/motion" "git.gophernest.net/azpect/TextEditor/internal/operator" ) @@ -115,7 +116,7 @@ func NewCommandKeymap() *Keymap { operators: map[string]action.Operator{}, // this will likely be empty actions: map[string]action.Action{ "esc": action.ExitCommandMode{}, - "enter": action.CommandExecute{}, + "enter": action.CommandExecute{Registry: command.DefaultRegistry}, "backspace": action.CommandBackspace{}, "delete": action.CommandDelete{}, "ctrl+w": action.CommandDeletePreviousWord{},