feat: added search actions and motions:
This commit is contained in:
parent
1aa1954d35
commit
514c77c1af
@ -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
146
internal/action/search.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -244,4 +244,10 @@ func (r *Registry) registerDefaults() {
|
||||
ShortForm: "u",
|
||||
Handler: cmdUndoList,
|
||||
})
|
||||
|
||||
r.Register(Command{
|
||||
Name: "search",
|
||||
ShortForm: "s",
|
||||
Handler: cmdSearch,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
22
internal/core/search.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
// ==================================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
37
internal/motion/search.go
Normal 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 }
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user