package motion import ( "git.gophernest.net/azpect/TextEditor/internal/action" "git.gophernest.net/azpect/TextEditor/internal/core" tea "github.com/charmbracelet/bubbletea" ) // isWordChar: Returns true if the character is a word character (alphanumeric // or underscore). func isWordChar(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' } // isWordPunctuation: Returns true if the character is punctuation (not whitespace // and not a word character). func isWordPunctuation(c byte) bool { return c != ' ' && c != '\t' && !isWordChar(c) } // nextWordStart: Finds the start of the next word from position (x,y), handling // word boundaries and line crossing. func nextWordStart(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) // Skip current class if x < len(line) { if isWordChar(line[x]) { for x < len(line) && isWordChar(line[x]) { x++ } } else if line[x] != ' ' && line[x] != '\t' { // punctuation class for x < len(line) && isWordPunctuation(line[x]) { x++ } } } // Skip whitespace and cross lines if needed for { // Walk over white space for x < len(line) && (line[x] == ' ' || line[x] == '\t') { x++ } // Were on the new word, nothing else to do (no lines to cross if x < len(line) { break } // If next line is the end of the file, exit now if y+1 >= buf.LineCount() { return x, y } // Move to first char of next line y++ line = buf.Line(y) x = 0 // If the first char of the new line is no whitespace, stay here! if len(line) > 0 && line[0] != ' ' && line[0] != '\t' { break } } return x, y } // nextWORDStart: Finds the start of the next WORD from position (x,y), treating // all non-whitespace as a single class. func nextWORDStart(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) // Skip current WORD (all non-whitespace is one class for W) for x < len(line) && line[x] != ' ' && line[x] != '\t' { x++ } // Skip whitespace and cross lines if needed for { // Walk over white space for x < len(line) && (line[x] == ' ' || line[x] == '\t') { x++ } // Were on the new word, nothing else to do (no lines to cross if x < len(line) { break } // If next line is the end of the file, exit now if y+1 >= buf.LineCount() { return x, y } // Move to first char of next line y++ line = buf.Line(y) x = 0 // If the first char of the new line is no whitespace, stay here! if len(line) > 0 && line[0] != ' ' && line[0] != '\t' { break } } return x, y } // nextWordEnd: Finds the end of the next word from position (x,y), respecting // word character classes. func nextWordEnd(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) // Advance once to avoid being stuck on the current end x++ if x >= len(line) { // At last line of file, pin cursor to end of file if y+1 >= buf.LineCount() { return len(line) - 1, y } // Otherwise, move to next line y++ x = 0 line = buf.Line(y) } // Skip whitespace and cross lines if needed for { // Walk over white space for x < len(line) && (line[x] == ' ' || line[x] == '\t') { x++ } // Were on the new word, nothing else to do (no lines to cross if x < len(line) { break } // If next line is the end of the file, exit now if y+1 >= buf.LineCount() { return x, y } // Move to first char of next line y++ line = buf.Line(y) x = 0 } // Move to end of current char class, stop before it ends if isWordChar(line[x]) { for x+1 < len(line) && isWordChar(line[x+1]) { x++ } } else { for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' && !isWordChar(line[x+1]) { x++ } } return x, y } // nextWORDEnd: Finds the end of the next WORD from position (x,y), treating // all non-whitespace as a single class. func nextWORDEnd(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) // Advance once to avoid being stuck on the current end x++ if x >= len(line) { // At last line of file, pin cursor to end of file if y+1 >= buf.LineCount() { return len(line) - 1, y } // Otherwise, move to next line y++ x = 0 line = buf.Line(y) } // Skip whitespace and cross lines if needed for { // Walk over white space for x < len(line) && (line[x] == ' ' || line[x] == '\t') { x++ } // Were on the new word, nothing else to do (no lines to cross if x < len(line) { break } // If next line is the end of the file, exit now if y+1 >= buf.LineCount() { return x, y } // Move to first char of next line y++ line = buf.Line(y) x = 0 } // Move to end of current WORD (all non-whitespace is one class) for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' { x++ } return x, y } // prevWordStart: Finds the start of the previous word from position (x,y), // moving backward through character classes. func prevWordStart(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) // Back one to avoid being stuck on the current start x-- if x < 0 { if y == 0 { return 0, 0 // beginning of file, stay put } y-- line = buf.Line(y) x = len(line) - 1 if x < 0 { return 0, y // landed on an empty line } } // Skip whitespace backward, crossing lines if needed for { for x >= 0 && (line[x] == ' ' || line[x] == '\t') { x-- } if x >= 0 { break // landed on a non-whitespace char } if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if len(line) == 0 { return 0, y // empty line acts as a word boundary } } // Skip to the start of the current char class if isWordChar(line[x]) { for x-1 >= 0 && isWordChar(line[x-1]) { x-- } } else { for x-1 >= 0 && isWordPunctuation(line[x-1]) { x-- } } return x, y } // MoveForwardWord implements Motion (w) - charwise type MoveForwardWord struct { Count int } // MoveForwardWord.Execute: Moves the cursor forward by Count words (w motion). func (a MoveForwardWord) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = nextWordStart(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveForwardWord.Type: Returns CharwiseExclusive for word motion. func (a MoveForwardWord) Type() core.MotionType { return core.CharwiseExclusive } // MoveForwardWord.WithCount: Returns a new MoveForwardWord with the given count. func (a MoveForwardWord) WithCount(n int) action.Action { return MoveForwardWord{Count: n} } // MoveForwardWORD implements Motion (W) - charwise type MoveForwardWORD struct { Count int } // MoveForwardWORD.Execute: Moves the cursor forward by Count WORDs (W motion). func (a MoveForwardWORD) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = nextWORDStart(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveForwardWORD.Type: Returns CharwiseExclusive for WORD motion. func (a MoveForwardWORD) Type() core.MotionType { return core.CharwiseExclusive } // MoveForwardWORD.WithCount: Returns a new MoveForwardWORD with the given count. func (a MoveForwardWORD) WithCount(n int) action.Action { return MoveForwardWORD{Count: n} } // MoveForwardWordEnd implements Motion (e) - charwise type MoveForwardWordEnd struct { Count int } // MoveForwardWordEnd.Execute: Moves the cursor to the end of the Count-th word (e motion). func (a MoveForwardWordEnd) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = nextWordEnd(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveForwardWordEnd.Type: Returns CharwiseInclusive for word-end motion. func (a MoveForwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive } // MoveForwardWordEnd.WithCount: Returns a new MoveForwardWordEnd with the given count. func (a MoveForwardWordEnd) WithCount(n int) action.Action { return MoveForwardWordEnd{Count: n} } // MoveForwardWORDEnd implements Motion (E) - charwise type MoveForwardWORDEnd struct { Count int } // MoveForwardWORDEnd.Execute: Moves the cursor to the end of the Count-th WORD (E motion). func (a MoveForwardWORDEnd) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = nextWORDEnd(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveForwardWORDEnd.Type: Returns CharwiseInclusive for WORD-end motion. func (a MoveForwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive } // MoveForwardWORDEnd.WithCount: Returns a new MoveForwardWORDEnd with the given count. func (a MoveForwardWORDEnd) WithCount(n int) action.Action { return MoveForwardWORDEnd{Count: n} } // MoveBackwardWord implements Motion (b) - charwise type MoveBackwardWord struct { Count int } // MoveBackwardWord.Execute: Moves the cursor backward by Count words (b motion). func (a MoveBackwardWord) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = prevWordStart(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveBackwardWord.Type: Returns CharwiseExclusive for backward word motion. func (a MoveBackwardWord) Type() core.MotionType { return core.CharwiseExclusive } // MoveBackwardWord.WithCount: Returns a new MoveBackwardWord with the given count. func (a MoveBackwardWord) WithCount(n int) action.Action { return MoveBackwardWord{Count: n} } // prevWORDStart: Finds the start of the previous WORD from position (x,y), // treating all non-whitespace as a single class. func prevWORDStart(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) // Back one to avoid being stuck on the current start x-- if x < 0 { if y == 0 { return 0, 0 // beginning of file, stay put } y-- line = buf.Line(y) x = len(line) - 1 if x < 0 { return 0, y // landed on an empty line } } // Skip whitespace backward, crossing lines if needed for { for x >= 0 && (line[x] == ' ' || line[x] == '\t') { x-- } if x >= 0 { break // landed on a non-whitespace char } if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if len(line) == 0 { return 0, y // empty line acts as a word boundary } } // Skip to the start of the WORD (all non-whitespace is one class) for x-1 >= 0 && line[x-1] != ' ' && line[x-1] != '\t' { x-- } return x, y } // prevWordEnd: Finds the end of the previous word from position (x,y), // respecting word character classes. func prevWordEnd(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) origY := y // Back one to avoid being stuck on the current end x-- if x < 0 { if y == 0 { return 0, 0 // beginning of file, stay put } y-- line = buf.Line(y) x = len(line) - 1 // Don't return early for empty line - we'll handle it in whitespace skip } // Skip backward through current word class if we're on one // BUT: if we crossed lines in the "back one" step, we're already at the end of a word if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' { if isWordChar(line[x]) { // Skip word characters for x >= 0 && isWordChar(line[x]) { x-- if x < 0 { if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if x < 0 { return 0, y } } } } else { // Skip punctuation for x >= 0 && isWordPunctuation(line[x]) { x-- if x < 0 { if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if x < 0 { return 0, y } } } } } // Skip whitespace backward, crossing lines if needed for { for x >= 0 && (line[x] == ' ' || line[x] == '\t') { x-- } if x >= 0 { break // landed on a non-whitespace char, this is our word end! } if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if len(line) == 0 { return 0, y // empty line acts as a word boundary } } // Now x,y is at the start of the target word. Move forward to its end. if x >= 0 { if isWordChar(line[x]) { for x+1 < len(line) && isWordChar(line[x+1]) { x++ } } else if isWordPunctuation(line[x]) { for x+1 < len(line) && isWordPunctuation(line[x+1]) { x++ } } } return x, y } // prevWORDEnd: Finds the end of the previous WORD from position (x,y), // treating all non-whitespace as a single class. func prevWORDEnd(buf *core.Buffer, x, y int) (int, int) { line := buf.Line(y) origY := y // Back one to avoid being stuck on the current end x-- if x < 0 { if y == 0 { return 0, 0 // beginning of file, stay put } y-- line = buf.Line(y) x = len(line) - 1 // Don't return early for empty line - we'll handle it in whitespace skip } // Skip backward through current WORD if we're on one // BUT: if we crossed lines in the "back one" step, we're already at the end of a WORD if y == origY && x >= 0 && line[x] != ' ' && line[x] != '\t' { for x >= 0 && line[x] != ' ' && line[x] != '\t' { x-- if x < 0 { if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if x < 0 { return 0, y } } } } // Skip whitespace backward, crossing lines if needed for { for x >= 0 && (line[x] == ' ' || line[x] == '\t') { x-- } if x >= 0 { break // landed on a non-whitespace char, this is our WORD end! } if y == 0 { return 0, 0 } y-- line = buf.Line(y) x = len(line) - 1 if len(line) == 0 { return 0, y // empty line acts as a word boundary } } // Now x,y is at the start of the target WORD. Move forward to its end. if x >= 0 { for x+1 < len(line) && line[x+1] != ' ' && line[x+1] != '\t' { x++ } } return x, y } // MoveBackwardWORD implements Motion (B) - charwise type MoveBackwardWORD struct { Count int } // MoveBackwardWORD.Execute: Moves the cursor backward by Count WORDs (B motion). func (a MoveBackwardWORD) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = prevWORDStart(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveBackwardWORD.Type: Returns CharwiseExclusive for backward WORD motion. func (a MoveBackwardWORD) Type() core.MotionType { return core.CharwiseExclusive } // MoveBackwardWORD.WithCount: Returns a new MoveBackwardWORD with the given count. func (a MoveBackwardWORD) WithCount(n int) action.Action { return MoveBackwardWORD{Count: n} } // MoveBackwardWordEnd implements Motion (ge) - charwise type MoveBackwardWordEnd struct { Count int } // MoveBackwardWordEnd.Execute: Moves the cursor to the end of the previous word (ge motion). func (a MoveBackwardWordEnd) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = prevWordEnd(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveBackwardWordEnd.Type: Returns CharwiseInclusive for backward word-end motion. func (a MoveBackwardWordEnd) Type() core.MotionType { return core.CharwiseInclusive } // MoveBackwardWordEnd.WithCount: Returns a new MoveBackwardWordEnd with the given count. func (a MoveBackwardWordEnd) WithCount(n int) action.Action { return MoveBackwardWordEnd{Count: n} } // MoveBackwardWORDEnd implements Motion (gE) - charwise type MoveBackwardWORDEnd struct { Count int } // MoveBackwardWORDEnd.Execute: Moves the cursor to the end of the previous WORD (gE motion). func (a MoveBackwardWORDEnd) Execute(m action.Model) tea.Cmd { win := m.ActiveWindow() buf := m.ActiveBuffer() x := win.Cursor.Col y := win.Cursor.Line for i := 0; i < a.Count; i++ { x, y = prevWORDEnd(buf, x, y) } win.SetCursorCol(x) win.SetCursorLine(y) return nil } // MoveBackwardWORDEnd.Type: Returns CharwiseInclusive for backward WORD-end motion. func (a MoveBackwardWORDEnd) Type() core.MotionType { return core.CharwiseInclusive } // MoveBackwardWORDEnd.WithCount: Returns a new MoveBackwardWORDEnd with the given count. func (a MoveBackwardWORDEnd) WithCount(n int) action.Action { return MoveBackwardWORDEnd{Count: n} }