diff --git a/internal/action/interface.go b/internal/action/interface.go index 095d608..60c54b9 100644 --- a/internal/action/interface.go +++ b/internal/action/interface.go @@ -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 // ================================================== diff --git a/internal/action/search.go b/internal/action/search.go new file mode 100644 index 0000000..f491aa5 --- /dev/null +++ b/internal/action/search.go @@ -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 +} diff --git a/internal/command/handlers.go b/internal/command/handlers.go index 2a0d751..5aacaaa 100644 --- a/internal/command/handlers.go +++ b/internal/command/handlers.go @@ -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 +} diff --git a/internal/command/registry.go b/internal/command/registry.go index 43f29ac..50d29fb 100644 --- a/internal/command/registry.go +++ b/internal/command/registry.go @@ -244,4 +244,10 @@ func (r *Registry) registerDefaults() { ShortForm: "u", Handler: cmdUndoList, }) + + r.Register(Command{ + Name: "search", + ShortForm: "s", + Handler: cmdSearch, + }) } diff --git a/internal/core/mode.go b/internal/core/mode.go index e679c28..0b308ce 100644 --- a/internal/core/mode.go +++ b/internal/core/mode.go @@ -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: diff --git a/internal/core/search.go b/internal/core/search.go new file mode 100644 index 0000000..3de4c4e --- /dev/null +++ b/internal/core/search.go @@ -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, + } +} diff --git a/internal/core/settings.go b/internal/core/settings.go index 617bfb6..b6699b2 100644 --- a/internal/core/settings.go +++ b/internal/core/settings.go @@ -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", } } diff --git a/internal/editor/model.go b/internal/editor/model.go index f0d3d5f..f172942 100644 --- a/internal/editor/model.go +++ b/internal/editor/model.go @@ -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 // ================================================== diff --git a/internal/editor/view.go b/internal/editor/view.go index 2e23814..e7f4d73 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -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 diff --git a/internal/input/handler.go b/internal/input/handler.go index ae3202a..82bae4e 100644 --- a/internal/input/handler.go +++ b/internal/input/handler.go @@ -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) { diff --git a/internal/input/keymap.go b/internal/input/keymap.go index e4ce00a..f6ac66b 100644 --- a/internal/input/keymap.go +++ b/internal/input/keymap.go @@ -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{}, + }, + } } diff --git a/internal/motion/search.go b/internal/motion/search.go new file mode 100644 index 0000000..86d22f3 --- /dev/null +++ b/internal/motion/search.go @@ -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 } diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 86d4bc7..c43b385 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -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