Most of the tests were just written poorly, the code was right. Though the yank related questions were actually broken.
684 lines
16 KiB
Go
684 lines
16 KiB
Go
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
|
|
}
|
|
}
|
|
|
|
// We're now at the end of the previous word - that's the answer!
|
|
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
|
|
}
|
|
}
|
|
|
|
// We're now at the end of the previous WORD - that's the answer!
|
|
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}
|
|
}
|