feat: rough command mode implementation

I am starting to develop so fast, testing is such a life saver, oh my
god.
This commit is contained in:
Hayden Hargreaves 2026-02-13 23:16:47 -07:00
parent 307f89bcd1
commit ee7bf9354b
12 changed files with 1027 additions and 47 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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)
}
})
}

View File

@ -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)
}
})
}

View File

@ -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

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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{},