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:
parent
307f89bcd1
commit
ee7bf9354b
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
222
internal/command/handlers.go
Normal file
222
internal/command/handlers.go
Normal 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
|
||||
}
|
||||
142
internal/command/registry.go
Normal file
142
internal/command/registry.go
Normal 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,
|
||||
})
|
||||
}
|
||||
235
internal/command/registry_test.go
Normal file
235
internal/command/registry_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
341
internal/editor/integration_command_test.go
Normal file
341
internal/editor/integration_command_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user