From 162544a63d16280c8cb2791f350f38759ba6c97e Mon Sep 17 00:00:00 2001 From: tg Date: Mon, 10 Sep 2018 14:33:22 +0300 Subject: [PATCH 1/3] added "punct" to word separator list for alt-{f, b, d}, ctrl-w --- line.go | 42 ++++++++++++++++++++++++------------------ rune.go | 8 ++++++++ 2 files changed, 32 insertions(+), 18 deletions(-) create mode 100644 rune.go diff --git a/line.go b/line.go index 076a195..afa8106 100644 --- a/line.go +++ b/line.go @@ -816,19 +816,19 @@ mainLoop: fmt.Print(beep) break } - // Remove whitespace to the left + // Remove word separators to the left var buf []rune // Store the deleted chars in a buffer for { - if pos == 0 || !unicode.IsSpace(line[pos-1]) { + if pos == 0 || !isWordSeparator(line[pos-1]) { break } buf = append(buf, line[pos-1]) line = append(line[:pos-1], line[pos:]...) pos-- } - // Remove non-whitespace to the left + // Remove non-word separators to the left for { - if pos == 0 || unicode.IsSpace(line[pos-1]) { + if pos == 0 || isWordSeparator(line[pos-1]) { break } buf = append(buf, line[pos-1]) @@ -897,19 +897,22 @@ mainLoop: } case wordLeft, altB: if pos > 0 { - var spaceHere, spaceLeft, leftKnown bool + var atWordSeparator, wordSeparatorLeft, leftKnown bool for { pos-- if pos == 0 { break } if leftKnown { - spaceHere = spaceLeft + atWordSeparator = wordSeparatorLeft } else { - spaceHere = unicode.IsSpace(line[pos]) + atWordSeparator = isWordSeparator(line[pos]) } - spaceLeft, leftKnown = unicode.IsSpace(line[pos-1]), true - if !spaceHere && spaceLeft { + + wordSeparatorLeft = isWordSeparator(line[pos-1]) + leftKnown = true + + if !atWordSeparator && wordSeparatorLeft { break } } @@ -924,19 +927,22 @@ mainLoop: } case wordRight, altF: if pos < len(line) { - var spaceHere, spaceLeft, hereKnown bool + var atWordSeparator, wordSeparatorLeft, hereKnown bool for { pos++ if pos == len(line) { break } if hereKnown { - spaceLeft = spaceHere + wordSeparatorLeft = atWordSeparator } else { - spaceLeft = unicode.IsSpace(line[pos-1]) + wordSeparatorLeft = isWordSeparator(line[pos-1]) } - spaceHere, hereKnown = unicode.IsSpace(line[pos]), true - if spaceHere && !spaceLeft { + + atWordSeparator = isWordSeparator(line[pos]) + hereKnown = true + + if atWordSeparator && !wordSeparatorLeft { break } } @@ -987,18 +993,18 @@ mainLoop: fmt.Print(beep) break } - // Remove whitespace to the right + // Remove word separators to the right var buf []rune // Store the deleted chars in a buffer for { - if pos == len(line) || !unicode.IsSpace(line[pos]) { + if pos == len(line) || !isWordSeparator(line[pos]) { break } buf = append(buf, line[pos]) line = append(line[:pos], line[pos+1:]...) } - // Remove non-whitespace to the right + // Remove non-word separators to the right for { - if pos == len(line) || unicode.IsSpace(line[pos]) { + if pos == len(line) || isWordSeparator(line[pos]) { break } buf = append(buf, line[pos]) diff --git a/rune.go b/rune.go new file mode 100644 index 0000000..83741e8 --- /dev/null +++ b/rune.go @@ -0,0 +1,8 @@ +package liner + +import "unicode" + +// isWordSeparator is used by alt-{F, B, D}, ctrl-{W} functions to determinate where to stop +func isWordSeparator(r rune) bool { + return unicode.IsSpace(r) || unicode.IsPunct(r) +} From 82b1ad247480b71591213d0f627cd7da81913b02 Mon Sep 17 00:00:00 2001 From: tg Date: Sun, 23 Sep 2018 18:17:49 +0300 Subject: [PATCH 2/3] liner, word separators: add configurable word separators --- common.go | 42 ++++++++++++++++++++++-------------- line.go | 24 ++++++++++++++------- rune.go | 8 ------- wordseparatorchecker.go | 30 ++++++++++++++++++++++++++ wordseparatorchecker_test.go | 42 ++++++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 32 deletions(-) delete mode 100644 rune.go create mode 100644 wordseparatorchecker.go create mode 100644 wordseparatorchecker_test.go diff --git a/common.go b/common.go index e16ecbc..67eb2ae 100644 --- a/common.go +++ b/common.go @@ -17,22 +17,23 @@ import ( ) type commonState struct { - terminalSupported bool - outputRedirected bool - inputRedirected bool - history []string - historyMutex sync.RWMutex - completer WordCompleter - columns int - killRing *ring.Ring - ctrlCAborts bool - r *bufio.Reader - tabStyle TabStyle - multiLineMode bool - cursorRows int - maxRows int - shouldRestart ShouldRestart - needRefresh bool + terminalSupported bool + outputRedirected bool + inputRedirected bool + history []string + historyMutex sync.RWMutex + completer WordCompleter + columns int + killRing *ring.Ring + ctrlCAborts bool + r *bufio.Reader + tabStyle TabStyle + multiLineMode bool + cursorRows int + maxRows int + shouldRestart ShouldRestart + needRefresh bool + wordSeparatorChecker WordSeparatorChecker } // TabStyle is used to select how tab completions are displayed. @@ -253,3 +254,12 @@ func (s *State) promptUnsupported(p string) (string, error) { } return string(linebuf), nil } + +// WordSeparatorChecker returns true if a rune should be consider as a word separator +type WordSeparatorChecker func(r rune) bool + +// SetWordSeparatorChecker sets a function that Liner will call to determine +// the end/beginning of a word +func (s *State) SetWordSeparatorChecker(ch WordSeparatorChecker) { + s.wordSeparatorChecker = ch +} diff --git a/line.go b/line.go index afa8106..9311ae8 100644 --- a/line.go +++ b/line.go @@ -819,7 +819,7 @@ mainLoop: // Remove word separators to the left var buf []rune // Store the deleted chars in a buffer for { - if pos == 0 || !isWordSeparator(line[pos-1]) { + if pos == 0 || !s.isWordSeparator(line[pos-1]) { break } buf = append(buf, line[pos-1]) @@ -828,7 +828,7 @@ mainLoop: } // Remove non-word separators to the left for { - if pos == 0 || isWordSeparator(line[pos-1]) { + if pos == 0 || s.isWordSeparator(line[pos-1]) { break } buf = append(buf, line[pos-1]) @@ -906,10 +906,10 @@ mainLoop: if leftKnown { atWordSeparator = wordSeparatorLeft } else { - atWordSeparator = isWordSeparator(line[pos]) + atWordSeparator = s.isWordSeparator(line[pos]) } - wordSeparatorLeft = isWordSeparator(line[pos-1]) + wordSeparatorLeft = s.isWordSeparator(line[pos-1]) leftKnown = true if !atWordSeparator && wordSeparatorLeft { @@ -936,10 +936,10 @@ mainLoop: if hereKnown { wordSeparatorLeft = atWordSeparator } else { - wordSeparatorLeft = isWordSeparator(line[pos-1]) + wordSeparatorLeft = s.isWordSeparator(line[pos-1]) } - atWordSeparator = isWordSeparator(line[pos]) + atWordSeparator = s.isWordSeparator(line[pos]) hereKnown = true if atWordSeparator && !wordSeparatorLeft { @@ -996,7 +996,7 @@ mainLoop: // Remove word separators to the right var buf []rune // Store the deleted chars in a buffer for { - if pos == len(line) || !isWordSeparator(line[pos]) { + if pos == len(line) || !s.isWordSeparator(line[pos]) { break } buf = append(buf, line[pos]) @@ -1004,7 +1004,7 @@ mainLoop: } // Remove non-word separators to the right for { - if pos == len(line) || isWordSeparator(line[pos]) { + if pos == len(line) || s.isWordSeparator(line[pos]) { break } buf = append(buf, line[pos]) @@ -1175,3 +1175,11 @@ func (s *State) tooNarrow(prompt string) (string, error) { } return s.promptUnsupported(prompt) } + +func (s *State) isWordSeparator(r rune) bool { + if s.wordSeparatorChecker == nil { + s.wordSeparatorChecker = SpaceWordSeparatorChecker + } + + return s.wordSeparatorChecker(r) +} diff --git a/rune.go b/rune.go deleted file mode 100644 index 83741e8..0000000 --- a/rune.go +++ /dev/null @@ -1,8 +0,0 @@ -package liner - -import "unicode" - -// isWordSeparator is used by alt-{F, B, D}, ctrl-{W} functions to determinate where to stop -func isWordSeparator(r rune) bool { - return unicode.IsSpace(r) || unicode.IsPunct(r) -} diff --git a/wordseparatorchecker.go b/wordseparatorchecker.go new file mode 100644 index 0000000..defca71 --- /dev/null +++ b/wordseparatorchecker.go @@ -0,0 +1,30 @@ +package liner + +import "unicode" + +// SpaceWordSeparatorChecker (default) returns true if r is a unicode-space +func SpaceWordSeparatorChecker(r rune) bool { + return unicode.IsSpace(r) +} + +// PunctWordSeparatorChecker returns true if r is a unicode punctuation character +func PunctWordSeparatorChecker(r rune) bool { + return unicode.IsPunct(r) +} + +// CombineWordSeparatorChecker combines checkers +// eg. line.SetWordSeparatorChecker(liner.CombineWordSeparatorChecker( +// liner.SpaceWordSeparatorChecker, +// liner.PunctWordSeparatorChecker, +// )) +func CombineWordSeparatorChecker(checkers ...WordSeparatorChecker) WordSeparatorChecker { + return func(r rune) bool { + for _, isSeparator := range checkers { + if isSeparator(r) { + return true + } + } + + return false + } +} diff --git a/wordseparatorchecker_test.go b/wordseparatorchecker_test.go new file mode 100644 index 0000000..aa536e3 --- /dev/null +++ b/wordseparatorchecker_test.go @@ -0,0 +1,42 @@ +package liner + +import ( + "testing" +) + +func TestSeparators(t *testing.T) { + // test spaces + spaces := []rune{' ', ' '} + for _, r := range spaces { + if !SpaceWordSeparatorChecker(r) { + t.Errorf("'%s' was not recognized by the space separator", string(r)) + } + } + + // test punctuation character + puncts := []rune{'(', ')', '{', '}'} // etc + for _, r := range puncts { + if !PunctWordSeparatorChecker(r) { + t.Errorf(`'%s' was not recognized by the "punctuation" separator`, string(r)) + } + } + + // test combination of them + combinedChecker := CombineWordSeparatorChecker( + SpaceWordSeparatorChecker, + PunctWordSeparatorChecker, + ) + for _, r := range append(puncts, spaces...) { + if !combinedChecker(r) { + t.Errorf("'%s' was not recognized by the combined separator", string(r)) + } + } + + // test some letters + justLetters := []rune{'a', 'b', 'c'} + for _, r := range justLetters { + if combinedChecker(r) { + t.Errorf("'%s' was recognized as a space or a punctuation char", string(r)) + } + } +} From 858ab3b4e672ebe27a719aa6d39831a5ab20ca12 Mon Sep 17 00:00:00 2001 From: tg Date: Tue, 2 Oct 2018 20:40:43 +0300 Subject: [PATCH 3/3] allow to change word related behavior (alf-f, ctrl-w and so on) --- bashwordcontroller.go | 58 ++++++++++++++ common.go | 44 +++++------ effect.go | 13 ++++ input.go | 2 + line.go | 121 +++++++++--------------------- wordcontroller.go | 162 ++++++++++++++++++++++++++++++++++++++++ wordseparatorchecker.go | 3 + 7 files changed, 292 insertions(+), 111 deletions(-) create mode 100644 bashwordcontroller.go create mode 100644 effect.go create mode 100644 wordcontroller.go diff --git a/bashwordcontroller.go b/bashwordcontroller.go new file mode 100644 index 0000000..83baccd --- /dev/null +++ b/bashwordcontroller.go @@ -0,0 +1,58 @@ +package liner + +type ( + // BashWordController describes bash-like behavior for the word related functions + BashWordController struct { + DefaultWordController + } +) + +// NewBashWordController returns default word controller with default settings +func NewBashWordController() BashWordController { + return BashWordController{ + DefaultWordController: DefaultWordController{ + wordSeparatorChecker: CombineWordSeparatorChecker( + SpaceWordSeparatorChecker, + PunctWordSeparatorChecker, + ), + }, + } +} + +// EraseWordBack returns an effect for ctrlW +func (bc BashWordController) EraseWordBack(line []rune, originalPos int) (effect, error) { + pos := originalPos + if pos == 0 { + return effect{newPosition: pos, beep: true}, nil + } + + // Remove word separators to the left + for { + if pos == 0 || !bc.isSpace(line[pos-1]) { + break + } + pos-- + } + + // Remove non-word separators to the left + for { + if pos == 0 || bc.isSpace(line[pos-1]) { + break + } + pos-- + } + + return effect{ + toDelete: &deleteEffect{ + from: pos, + to: originalPos, + }, + newPosition: pos, + }, nil +} + +func (bc BashWordController) isSpace(r rune) bool { + return SpaceWordSeparatorChecker(r) +} + +var _ WordController = (*BashWordController)(nil) diff --git a/common.go b/common.go index 67eb2ae..b537884 100644 --- a/common.go +++ b/common.go @@ -17,23 +17,23 @@ import ( ) type commonState struct { - terminalSupported bool - outputRedirected bool - inputRedirected bool - history []string - historyMutex sync.RWMutex - completer WordCompleter - columns int - killRing *ring.Ring - ctrlCAborts bool - r *bufio.Reader - tabStyle TabStyle - multiLineMode bool - cursorRows int - maxRows int - shouldRestart ShouldRestart - needRefresh bool - wordSeparatorChecker WordSeparatorChecker + terminalSupported bool + outputRedirected bool + inputRedirected bool + history []string + historyMutex sync.RWMutex + completer WordCompleter + columns int + killRing *ring.Ring + ctrlCAborts bool + r *bufio.Reader + tabStyle TabStyle + multiLineMode bool + cursorRows int + maxRows int + shouldRestart ShouldRestart + needRefresh bool + wordController WordController } // TabStyle is used to select how tab completions are displayed. @@ -255,11 +255,7 @@ func (s *State) promptUnsupported(p string) (string, error) { return string(linebuf), nil } -// WordSeparatorChecker returns true if a rune should be consider as a word separator -type WordSeparatorChecker func(r rune) bool - -// SetWordSeparatorChecker sets a function that Liner will call to determine -// the end/beginning of a word -func (s *State) SetWordSeparatorChecker(ch WordSeparatorChecker) { - s.wordSeparatorChecker = ch +// SetWordController sets a behavior for word jump related functions +func (s *State) SetWordController(wc WordController) { + s.wordController = wc } diff --git a/effect.go b/effect.go new file mode 100644 index 0000000..8c68db4 --- /dev/null +++ b/effect.go @@ -0,0 +1,13 @@ +package liner + +type ( + deleteEffect struct { + from, to int + } + + effect struct { + beep bool + toDelete *deleteEffect + newPosition int // position after modification + } +) diff --git a/input.go b/input.go index cdb8330..d75aeda 100644 --- a/input.go +++ b/input.go @@ -65,6 +65,8 @@ func NewLiner() *State { s.outputRedirected = !s.getColumns() } + s.wordController = NewDefaultWordController() + return &s } diff --git a/line.go b/line.go index 9311ae8..586dd05 100644 --- a/line.go +++ b/line.go @@ -812,29 +812,20 @@ mainLoop: pos = 0 s.needRefresh = true case ctrlW: // Erase word - if pos == 0 { + effect, _ := s.wordController.EraseWordBack(line, pos) + if effect.beep { fmt.Print(beep) - break - } - // Remove word separators to the left - var buf []rune // Store the deleted chars in a buffer - for { - if pos == 0 || !s.isWordSeparator(line[pos-1]) { - break - } - buf = append(buf, line[pos-1]) - line = append(line[:pos-1], line[pos:]...) - pos-- } - // Remove non-word separators to the left - for { - if pos == 0 || s.isWordSeparator(line[pos-1]) { - break - } - buf = append(buf, line[pos-1]) - line = append(line[:pos-1], line[pos:]...) - pos-- + if effect.toDelete == nil { + break } + + toDelete := effect.toDelete + buf := line[toDelete.from:toDelete.to] + line = append(line[:toDelete.from], line[toDelete.to:]...) + + pos = effect.newPosition + // Invert the buffer and save the result on the killRing var newBuf []rune for i := len(buf) - 1; i >= 0; i-- { @@ -896,29 +887,12 @@ mainLoop: fmt.Print(beep) } case wordLeft, altB: - if pos > 0 { - var atWordSeparator, wordSeparatorLeft, leftKnown bool - for { - pos-- - if pos == 0 { - break - } - if leftKnown { - atWordSeparator = wordSeparatorLeft - } else { - atWordSeparator = s.isWordSeparator(line[pos]) - } - - wordSeparatorLeft = s.isWordSeparator(line[pos-1]) - leftKnown = true - - if !atWordSeparator && wordSeparatorLeft { - break - } - } - } else { + effect, _ := s.wordController.WordLeft(line, pos) + if effect.beep { fmt.Print(beep) } + + pos = effect.newPosition case right: if pos < len(line) { pos += len(getPrefixGlyphs(line[pos:], 1)) @@ -926,29 +900,11 @@ mainLoop: fmt.Print(beep) } case wordRight, altF: - if pos < len(line) { - var atWordSeparator, wordSeparatorLeft, hereKnown bool - for { - pos++ - if pos == len(line) { - break - } - if hereKnown { - wordSeparatorLeft = atWordSeparator - } else { - wordSeparatorLeft = s.isWordSeparator(line[pos-1]) - } - - atWordSeparator = s.isWordSeparator(line[pos]) - hereKnown = true - - if atWordSeparator && !wordSeparatorLeft { - break - } - } - } else { + effect, _ := s.wordController.WordRight(line, pos) + if effect.beep { fmt.Print(beep) } + pos = effect.newPosition case up: historyAction = true if historyStale { @@ -989,27 +945,26 @@ mainLoop: case end: // End of line pos = len(line) case altD: // Delete next word - if pos == len(line) { + effect, _ := s.wordController.DeleteNextWord(line, pos) + if effect.beep { fmt.Print(beep) - break } - // Remove word separators to the right - var buf []rune // Store the deleted chars in a buffer - for { - if pos == len(line) || !s.isWordSeparator(line[pos]) { - break - } - buf = append(buf, line[pos]) - line = append(line[:pos], line[pos+1:]...) + if effect.toDelete == nil { + break } - // Remove non-word separators to the right - for { - if pos == len(line) || s.isWordSeparator(line[pos]) { - break - } - buf = append(buf, line[pos]) - line = append(line[:pos], line[pos+1:]...) + + toDelete := effect.toDelete + buf := line[toDelete.from:toDelete.to] + line = append(line[:toDelete.from], line[toDelete.to:]...) + + pos = effect.newPosition + + // Invert the buffer and save the result on the killRing + var newBuf []rune + for i := len(buf) - 1; i >= 0; i-- { + newBuf = append(newBuf, buf[i]) } + // Save the result on the killRing if killAction > 0 { s.addToKillRing(buf, 2) // Add in prepend mode @@ -1175,11 +1130,3 @@ func (s *State) tooNarrow(prompt string) (string, error) { } return s.promptUnsupported(prompt) } - -func (s *State) isWordSeparator(r rune) bool { - if s.wordSeparatorChecker == nil { - s.wordSeparatorChecker = SpaceWordSeparatorChecker - } - - return s.wordSeparatorChecker(r) -} diff --git a/wordcontroller.go b/wordcontroller.go new file mode 100644 index 0000000..07c75ee --- /dev/null +++ b/wordcontroller.go @@ -0,0 +1,162 @@ +package liner + +type ( + // WordController provides interface for word related actions + WordController interface { + // an effect for ctrlW + EraseWordBack(line []rune, pos int) (effect, error) + // an effect for alt-d + DeleteNextWord(line []rune, pos int) (effect, error) + + // an effect for alt-b + WordLeft(line []rune, pos int) (effect, error) + // an affect for alt-f + WordRight(line []rune, pos int) (effect, error) + } + + // DefaultWordController describes default behavior for the word related functions + DefaultWordController struct { + wordSeparatorChecker WordSeparatorChecker + } +) + +// NewDefaultWordController returns default word controller with default settings +func NewDefaultWordController() DefaultWordController { + return DefaultWordController{ + wordSeparatorChecker: SpaceWordSeparatorChecker, + } +} + +// EraseWordBack returns an effect for ctrlW +func (bc DefaultWordController) EraseWordBack(line []rune, originalPos int) (effect, error) { + pos := originalPos + if pos == 0 { + return effect{newPosition: pos, beep: true}, nil + } + + // Remove word separators to the left + for { + if pos == 0 || !bc.isWordSeparator(line[pos-1]) { + break + } + pos-- + } + + // Remove non-word separators to the left + for { + if pos == 0 || bc.isWordSeparator(line[pos-1]) { + break + } + pos-- + } + + return effect{ + toDelete: &deleteEffect{ + from: pos, + to: originalPos, + }, + newPosition: pos, + }, nil +} + +// WordLeft returns an effect for alt-b +func (bc DefaultWordController) WordLeft(line []rune, pos int) (effect, error) { + if pos == 0 { + return effect{newPosition: pos, beep: true}, nil + } + + var atWordSeparator, wordSeparatorLeft, leftKnown bool + for { + pos-- + if pos == 0 { + break + } + if leftKnown { + atWordSeparator = wordSeparatorLeft + } else { + atWordSeparator = bc.isWordSeparator(line[pos]) + } + + wordSeparatorLeft = bc.isWordSeparator(line[pos-1]) + leftKnown = true + + if !atWordSeparator && wordSeparatorLeft { + break + } + } + + return effect{newPosition: pos}, nil +} + +// WordRight returns an effect for alt-f +func (bc DefaultWordController) WordRight(line []rune, pos int) (effect, error) { + if pos >= len(line) { + return effect{beep: true, newPosition: pos}, nil + } + + var atWordSeparator, wordSeparatorLeft, hereKnown bool + for { + pos++ + if pos == len(line) { + break + } + if hereKnown { + wordSeparatorLeft = atWordSeparator + } else { + wordSeparatorLeft = bc.isWordSeparator(line[pos-1]) + } + + atWordSeparator = bc.isWordSeparator(line[pos]) + hereKnown = true + + if atWordSeparator && !wordSeparatorLeft { + break + } + } + + return effect{newPosition: pos}, nil +} + +// DeleteNextWord return an effect for alt-d +func (bc DefaultWordController) DeleteNextWord(line []rune, originalPos int) (effect, error) { + if originalPos == len(line) { + return effect{beep: true}, nil + + } + + virtualPosition := originalPos + // Remove word separators to the right + for { + if virtualPosition == len(line) || !bc.isWordSeparator(line[virtualPosition]) { + break + } + virtualPosition++ + } + + // Remove non-word separators to the right + for { + if virtualPosition == len(line) || bc.isWordSeparator(line[virtualPosition]) { + break + } + virtualPosition++ + } + + return effect{ + toDelete: &deleteEffect{ + from: originalPos, + to: virtualPosition, + }, + newPosition: originalPos, + }, nil +} + +// SetWordSeparatorChecker sets word separator strategy +func (bc *DefaultWordController) SetWordSeparatorChecker(ws WordSeparatorChecker) { + bc.wordSeparatorChecker = ws +} + +func (bc *DefaultWordController) isWordSeparator(r rune) bool { + return bc.wordSeparatorChecker(r) +} + +var _ WordController = (*DefaultWordController)(nil) diff --git a/wordseparatorchecker.go b/wordseparatorchecker.go index defca71..820ddf7 100644 --- a/wordseparatorchecker.go +++ b/wordseparatorchecker.go @@ -2,6 +2,9 @@ package liner import "unicode" +// WordSeparatorChecker returns true if a rune should be consider as a word separator +type WordSeparatorChecker func(r rune) bool + // SpaceWordSeparatorChecker (default) returns true if r is a unicode-space func SpaceWordSeparatorChecker(r rune) bool { return unicode.IsSpace(r)