feat: added search actions and motions:

This commit is contained in:
Hayden Hargreaves 2026-04-09 14:36:18 -07:00
parent 1aa1954d35
commit 514c77c1af
13 changed files with 327 additions and 3 deletions

View File

@ -46,6 +46,12 @@ type Model interface {
CommandHistoryCursor() int
SetCommandHistoryCursor(cur int)
// ==================================================
// Search Mode State
// ==================================================
SearchState() core.SearchState
SetSearchState(s core.SearchState)
// ==================================================
// Editor-wide State
// ==================================================

146
internal/action/search.go Normal file
View File

@ -0,0 +1,146 @@
package action
import (
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
type EnterSearchMode struct {
Forward bool
}
func (a EnterSearchMode) Execute(m Model) tea.Cmd {
search := m.SearchState()
search.Forword = a.Forward
m.SetSearchState(search)
m.SetMode(core.SearchMode)
return nil
}
type ExitSearchMode struct{}
func (a ExitSearchMode) Execute(m Model) tea.Cmd {
// Reset state
search := m.SearchState()
if strings.TrimSpace(search.Query) != "" {
search.History = append(search.History, search.Query)
}
search.Cursor = 0
search.Query = ""
search.HistoryCursor = 0
// TODO: Maybe we want to keep Query until we enter it again next, for N and n?
m.SetSearchState(search)
m.SetMode(core.NormalMode)
return nil
}
type InsertSearchChar struct {
Char string
}
// InsertSearchChar.Execute: Inserts a character at the search cursor position.
func (a InsertSearchChar) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
query := search.Query
search.Query = query[:cur] + a.Char + query[cur:]
search.Cursor++
m.SetSearchState(search)
return nil
}
// SearchBackspace implements Action - deletes character before cursor in search mode.
type SearchBackspace struct{}
// SearchBackspace.Execute: Deletes the character before the search cursor (Backspace key).
func (a SearchBackspace) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
query := search.Query
if cur > 0 {
search.Query = query[:cur-1] + query[cur:]
search.Cursor--
m.SetSearchState(search)
}
return nil
}
// SearchDelete implements Action - deletes character at cursor in search mode.
type SearchDelete struct{}
// SearchDelete.Execute: Deletes the character at the command cursor (Delete key).
func (a SearchDelete) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
query := search.Query
if cur < len(query)-1 {
search.Query = query[:cur+1] + query[cur+2:]
} else if cur == len(query)-1 {
// last text char, delete it
search.Query = query[:cur] + query[cur+1:]
} else if cur == len(query) && cur > 0 {
// if at end, we do backspace op
search.Query = query[:cur-1] + query[cur:]
search.Cursor = max(0, search.Cursor-1)
}
m.SetSearchState(search)
return nil
}
// SearchDeletePreviousWord implements Action - deletes word before cursor in search mode.
type SearchDeletePreviousWord struct{}
// SearchDeletePreviousWord.Execute: Deletes the word before the search cursor (Ctrl+W).
func (a SearchDeletePreviousWord) Execute(m Model) tea.Cmd {
search := m.SearchState()
cur := search.Cursor
cmd := search.Query
if cur > 0 {
newCur := cur
// If we are on punctuation, we should just skip them all and quit
if isPunctuation(cmd[newCur-1]) {
for newCur > 0 && isPunctuation(cmd[newCur-1]) {
newCur--
}
search.Query = cmd[:newCur] + cmd[cur:]
search.Cursor = newCur
m.SetSearchState(search)
return nil
}
// Skip whitespace immediately before the cursor
for newCur > 0 && (cmd[newCur-1] == ' ' || cmd[newCur-1] == '\t') {
newCur--
}
// Skip the word characters before the cursor
for newCur > 0 && isWordChar(cmd[newCur-1]) {
newCur--
}
// Delete everything from newCur up to cur in one operation
search.Query = cmd[:newCur] + cmd[cur:]
search.Cursor = newCur
m.SetSearchState(search)
}
return nil
}

View File

@ -959,3 +959,27 @@ func cmdUndoList(m action.Model, args []string, force bool) tea.Cmd {
return nil
}
func cmdSearch(m action.Model, args []string, force bool) tea.Cmd {
_, _ = args, force
search := m.SearchState()
lines := []string{
fmt.Sprintf("Query: %s", search.Query),
fmt.Sprintf("Forward: %v", search.Forword),
fmt.Sprintf("Cursor: %d", search.Cursor),
fmt.Sprintf("HistoryCursor: %d", search.HistoryCursor),
fmt.Sprintf("History: %q", search.History),
}
m.SetMode(core.CommandOutputMode)
m.SetCommandOutput(&core.CommandOutput{
Title: ":search",
Lines: lines,
Inline: false,
IsError: false,
})
return nil
}

View File

@ -244,4 +244,10 @@ func (r *Registry) registerDefaults() {
ShortForm: "u",
Handler: cmdUndoList,
})
r.Register(Command{
Name: "search",
ShortForm: "s",
Handler: cmdSearch,
})
}

View File

@ -13,6 +13,7 @@ const (
VisualBlockMode
ReplaceMode
WaitingMode // Same as NORMAL output, but cursor is the REPLACE cursor
SearchMode
)
// Mode.ToString: Returns a human-readable string representation of the mode
@ -25,6 +26,8 @@ func (m Mode) ToString() string {
return "INSERT"
case CommandMode:
return "COMMAND"
case SearchMode:
return "SEARCH"
case VisualMode:
return "VISUAL"
case VisualLineMode:

22
internal/core/search.go Normal file
View File

@ -0,0 +1,22 @@
package core
type SearchState struct {
Query string
Forword bool
Cursor int
// History is editor wide
History []string
HistoryCursor int
}
func NewSearchState() SearchState {
return SearchState{
Query: "",
Forword: true,
Cursor: 0,
History: []string{},
HistoryCursor: 0,
}
}

View File

@ -12,6 +12,6 @@ func NewDefaultSettings() EditorSettings {
return EditorSettings{
TabStop: 2,
// TODO: This should be "default" but until we have a startup config, this is fine
CurrentTheme: "kanagawa",
CurrentTheme: "default",
}
}

View File

@ -44,6 +44,9 @@ type Model struct {
commandHistory []string
commandHistoryCursor int
// Search state
searchState core.SearchState
// Global settings
settings core.EditorSettings
@ -328,6 +331,18 @@ func (m *Model) SetCommandHistoryCursor(cur int) {
m.commandHistoryCursor = cur
}
// ==================================================
// Search Mode State
// ==================================================
func (m *Model) SearchState() core.SearchState {
return m.searchState
}
func (m *Model) SetSearchState(s core.SearchState) {
m.searchState = s
}
// ==================================================
// Editor-wide State
// ==================================================

View File

@ -261,6 +261,29 @@ func drawCommandBar(m Model, t theme.EditorTheme) string {
leftBar = t.Line.Render(content)
}
// We only display when when we are currently searching
if m.Mode() == core.SearchMode {
search := m.SearchState()
if search.Forword {
leftBar = t.Line.Render("/")
} else {
leftBar = t.Line.Render("?")
}
for i, r := range search.Query {
if i == search.Cursor {
// TODO: Make sure other themes support this
leftBar += t.DefaultCursor(m.Mode()).Render(string(r))
} else {
leftBar += t.Line.Render(string(r))
}
}
// Cursor at end of command
if search.Cursor >= len(search.Query) {
leftBar += t.DefaultCursor(m.Mode()).Render(" ")
}
}
// Compute right bar
var rightBar string

View File

@ -43,6 +43,7 @@ type Handler struct {
insertKeymap *Keymap
replaceKeymap *Keymap
commandKeymap *Keymap
searchKeymap *Keymap
currentKeymap *Keymap
}
@ -56,6 +57,7 @@ func NewHandler() *Handler {
insertKeymap: NewInsertKeymap(),
replaceKeymap: NewReplaceKeymap(),
commandKeymap: NewCommandKeymap(),
searchKeymap: NewSearchKeymap(),
currentKeymap: nil,
}
}
@ -71,7 +73,7 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
}
// ESC always resets everything
if key == "esc" {
if key == "esc" && m.Mode() != core.SearchMode {
// If insert mode, keep the escape
if m.Mode() == core.InsertMode {
m.SetLastChangeKeys(append(m.LastChangeKeys(), key))
@ -101,6 +103,8 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
return h.handleReplaceKey(m, key)
case core.CommandMode:
return h.handleCommandKey(m, key)
case core.SearchMode:
return h.handleSearchKey(m, key)
}
// If waiting for character argument (f/t/F/T), capture it
@ -616,6 +620,22 @@ func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
return action.InsertCommandChar{Char: key}.Execute(m)
}
// Handler.handleSearchKey: Processes a keypress in search mode, executing
// it as an action or inserting it into the search line. This does not record
// anything into the undo stack.
func (h *Handler) handleSearchKey(m action.Model, key string) tea.Cmd {
kind, binding := h.searchKeymap.Lookup(key)
switch kind {
case "action":
return binding.(action.Action).Execute(m)
case "motion":
return binding.(action.Motion).Execute(m)
}
// Fallback: treat as a regular character to insert
return action.InsertSearchChar{Char: key}.Execute(m)
}
// normalizeVisualSelection: Returns the visual selection with start before end,
// regardless of which direction the selection was made.
func normalizeVisualSelection(m action.Model) (core.Position, core.Position) {

View File

@ -84,6 +84,8 @@ func NewNormalKeymap() *Keymap {
"R": action.EnterReplace{},
"J": action.JoinLines{Preserve: false},
"gJ": action.JoinLines{Preserve: true},
"/": action.EnterSearchMode{Forward: true},
"?": action.EnterSearchMode{Forward: false},
},
charMotions: map[string]action.Motion{
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
@ -257,6 +259,26 @@ func NewCommandKeymap() *Keymap {
"ctrl+w": action.CommandDeletePreviousWord{},
},
}
}
// NewSearchKeymap: Creates a keymap for search mode with command line editing.
func NewSearchKeymap() *Keymap {
return &Keymap{
motions: map[string]action.Motion{
"left": motion.MoveSearchLeft{},
"right": motion.MoveSearchRight{},
// "up": motion.MoveCommandHistoryUp{},
// "down": motion.MoveCommandHistoryDown{},
},
operators: map[string]action.Operator{}, // this will likely be empty
actions: map[string]action.Action{
"esc": action.ExitSearchMode{},
// "enter": action.CommandExecute{Registry: command.DefaultRegistry},
"backspace": action.SearchBackspace{},
"delete": action.SearchDelete{},
"ctrl+w": action.SearchDeletePreviousWord{},
},
}
}

37
internal/motion/search.go Normal file
View File

@ -0,0 +1,37 @@
package motion
import (
"git.gophernest.net/azpect/TextEditor/internal/action"
"git.gophernest.net/azpect/TextEditor/internal/core"
tea "github.com/charmbracelet/bubbletea"
)
// MoveSearchLeft implements Motion - moves cursor left in search line.
type MoveSearchLeft struct{}
// MoveSearchLeft.Execute: Moves the search line cursor one position to the left.
func (a MoveSearchLeft) Execute(m action.Model) tea.Cmd {
search := m.SearchState()
search.Cursor = max(0, search.Cursor-1)
m.SetSearchState(search)
return nil
}
// MoveSearchLeft.Type: Returns CharwiseExclusive for search line motion.
func (a MoveSearchLeft) Type() core.MotionType { return core.CharwiseExclusive }
// MoveSearchRight implements Motion - moves cursor right in search line.
type MoveSearchRight struct{}
// MoveSearchRight.Execute: Moves the search line cursor one position to the right.
func (a MoveSearchRight) Execute(m action.Model) tea.Cmd {
search := m.SearchState()
search.Cursor = min(search.Cursor+1, len(search.Query))
m.SetSearchState(search)
return nil
}
// MoveSearchRight.Type: Returns CharwiseExclusive for search line motion.
func (a MoveSearchRight) Type() core.MotionType { return core.CharwiseExclusive }

View File

@ -68,7 +68,7 @@ func (t EditorTheme) DefaultCursor(mode core.Mode) lipgloss.Style {
switch mode {
case core.InsertMode:
return t.Cursors.Insert
case core.CommandMode:
case core.CommandMode, core.SearchMode:
return t.Cursors.Command
case core.ReplaceMode:
return t.Cursors.Replace