2 Commits

Author SHA1 Message Date
Christian Rocha
d3192a3e70 Update text input viewport scrolling to handle unicode characters 2020-06-11 21:24:41 -04:00
Christian Rocha
74cc86fce5 Use a slice of runes as the underlying textinput value 2020-06-11 18:35:18 -04:00
3 changed files with 71 additions and 36 deletions

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.13
require ( require (
github.com/charmbracelet/bubbletea v0.8.0 github.com/charmbracelet/bubbletea v0.8.0
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83
golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 // indirect
golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 // indirect

2
go.sum
View File

@@ -6,6 +6,8 @@ github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tW
github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 h1:AfshZBlqAwhCZ27NJ1aPlMcPBihF1squ1GpaollhLQk= github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83 h1:AfshZBlqAwhCZ27NJ1aPlMcPBihF1squ1GpaollhLQk=
github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI= github.com/muesli/termenv v0.5.3-0.20200526053627-d728968dcf83/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI=
github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU= github.com/pkg/term v0.0.0-20200520122047-c3ffed290a03 h1:pd4YKIqCB0U7O2I4gWHgEUA2mCEOENmco0l/bM957bU=

View File

@@ -6,6 +6,7 @@ import (
"unicode" "unicode"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth"
"github.com/muesli/termenv" "github.com/muesli/termenv"
) )
@@ -45,7 +46,7 @@ type Model struct {
Width int Width int
// Underlying text value // Underlying text value
value string value []rune
// Focus indicates whether user input focus should be on this input // Focus indicates whether user input focus should be on this input
// component. When false, don't blink and ignore keyboard input. // component. When false, don't blink and ignore keyboard input.
@@ -60,14 +61,16 @@ type Model struct {
// Used to emulate a viewport when width is set and the content is // Used to emulate a viewport when width is set and the content is
// overflowing // overflowing
offset int offset int
offsetRight int
} }
// SetValue sets the value of the text input. // SetValue sets the value of the text input.
func (m *Model) SetValue(s string) { func (m *Model) SetValue(s string) {
if m.CharLimit > 0 && len(s) > m.CharLimit { runes := []rune(s)
m.value = s[:m.CharLimit] if m.CharLimit > 0 && len(runes) > m.CharLimit {
m.value = runes[:m.CharLimit]
} else { } else {
m.value = s m.value = runes
} }
if m.pos > len(m.value) { if m.pos > len(m.value) {
m.pos = len(m.value) m.pos = len(m.value)
@@ -77,13 +80,13 @@ func (m *Model) SetValue(s string) {
// Value returns the value of the text input. // Value returns the value of the text input.
func (m Model) Value() string { func (m Model) Value() string {
return m.value return string(m.value)
} }
// Cursor start moves the cursor to the given position. If the position is out // Cursor start moves the cursor to the given position. If the position is out
// of bounds the cursor will be moved to the start or end accordingly. // of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) { func (m *Model) SetCursor(pos int) {
m.pos = max(0, min(len(m.value), pos)) m.pos = clamp(pos, 0, len(m.value))
m.handleOverflow() m.handleOverflow()
} }
@@ -118,8 +121,7 @@ func (m *Model) Blur() {
// Reset sets the input to its default state with no input. // Reset sets the input to its default state with no input.
func (m *Model) Reset() { func (m *Model) Reset() {
m.value = "" m.value = nil
m.offset = 0
m.pos = 0 m.pos = 0
m.blink = false m.blink = false
} }
@@ -127,14 +129,46 @@ func (m *Model) Reset() {
// If a max width is defined, perform some logic to treat the visible area // If a max width is defined, perform some logic to treat the visible area
// as a horizontally scrolling viewport. // as a horizontally scrolling viewport.
func (m *Model) handleOverflow() { func (m *Model) handleOverflow() {
if m.Width > 0 { if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width {
overflow := max(0, len(m.value)-m.Width) m.offset = 0
m.offsetRight = len(m.value)
if overflow > 0 && m.pos < m.offset { return
m.offset = max(0, min(len(m.value), m.pos))
} else if overflow > 0 && m.pos >= m.offset+m.Width {
m.offset = max(0, m.pos-m.Width)
} }
if m.pos < m.offset {
m.offset = m.pos
w := 0
i := 0
runes := m.value[m.offset:]
for i < len(runes) && w <= m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width+1 {
i++
}
}
m.offsetRight = m.offset + i
} else if m.pos >= m.offsetRight {
m.offsetRight = m.pos
w := 0
runes := m.value[:m.offsetRight]
i := len(runes) - 1
for i > 0 && w < m.Width {
w += rw.RuneWidth(runes[i])
if w <= m.Width {
i--
}
}
m.offset = m.offsetRight - (len(runes) - 1 - i)
} }
} }
@@ -166,7 +200,7 @@ func (m *Model) wordLeft() {
i := m.pos - 1 i := m.pos - 1
for i >= 0 { for i >= 0 {
if unicode.IsSpace(rune(m.value[i])) { if unicode.IsSpace(m.value[i]) {
m.pos-- m.pos--
i-- i--
} else { } else {
@@ -175,7 +209,7 @@ func (m *Model) wordLeft() {
} }
for i >= 0 { for i >= 0 {
if !unicode.IsSpace(rune(m.value[i])) { if !unicode.IsSpace(m.value[i]) {
m.pos-- m.pos--
i-- i--
} else { } else {
@@ -224,7 +258,7 @@ func NewModel() Model {
CursorColor: "", CursorColor: "",
CharLimit: 0, CharLimit: 0,
value: "", value: nil,
focus: false, focus: false,
blink: true, blink: true,
pos: 0, pos: 0,
@@ -245,7 +279,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
fallthrough fallthrough
case tea.KeyDelete: case tea.KeyDelete:
if len(m.value) > 0 { if len(m.value) > 0 {
m.value = m.value[:m.pos-1] + m.value[m.pos:] m.value = append(m.value[:m.pos-1], m.value[m.pos:]...)
m.pos-- m.pos--
} }
case tea.KeyLeft: case tea.KeyLeft:
@@ -272,7 +306,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
m.CursorStart() m.CursorStart()
case tea.KeyCtrlD: // ^D, delete char under cursor case tea.KeyCtrlD: // ^D, delete char under cursor
if len(m.value) > 0 && m.pos < len(m.value) { if len(m.value) > 0 && m.pos < len(m.value) {
m.value = m.value[:m.pos] + m.value[m.pos+1:] m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
} }
case tea.KeyCtrlE: // ^E, go to end case tea.KeyCtrlE: // ^E, go to end
m.CursorEnd() m.CursorEnd()
@@ -298,7 +332,7 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
// Input a regular character // Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit { if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
m.value = m.value[:m.pos] + string(msg.Rune) + m.value[m.pos:] m.value = append(m.value[:m.pos], append([]rune{msg.Rune}, m.value[m.pos:]...)...)
m.pos++ m.pos++
} }
} }
@@ -324,34 +358,28 @@ func View(model tea.Model) string {
} }
// Placeholder text // Placeholder text
if m.value == "" && m.Placeholder != "" { if m.value == nil && m.Placeholder != "" {
return placeholderView(m) return placeholderView(m)
} }
left := m.offset value := m.value[m.offset:m.offsetRight]
right := 0
if m.Width > 0 {
right = min(len(m.value), m.offset+m.Width+1)
} else {
right = len(m.value)
}
value := m.value[left:right]
pos := m.pos - m.offset pos := m.pos - m.offset
v := m.colorText(value[:pos]) v := m.colorText(string(value[:pos]))
if pos < len(value) { if pos < len(value) {
v += cursorView(string(value[pos]), m) // cursor and text under it v += cursorView(string(value[pos]), m) // cursor and text under it
v += m.colorText(value[pos+1:]) // text after cursor v += m.colorText(string(value[pos+1:])) // text after cursor
} else { } else {
v += cursorView(" ", m) v += cursorView(" ", m)
} }
// If a max width and background color were set fill the empty spaces with // If a max width and background color were set fill the empty spaces with
// the background color. // the background color.
if m.Width > 0 && len(m.BackgroundColor) > 0 && len(value) <= m.Width { valWidth := rw.StringWidth(string(value))
padding := m.Width - len(value) if m.Width > 0 && len(m.BackgroundColor) > 0 && valWidth <= m.Width {
if len(value)+padding <= m.Width && pos < len(value) { padding := max(0, m.Width-valWidth)
if valWidth+padding <= m.Width && pos < len(value) {
padding++ padding++
} }
v += strings.Repeat( v += strings.Repeat(
@@ -412,6 +440,10 @@ func Blink(model Model) tea.Cmd {
} }
} }
func clamp(v, low, high int) int {
return min(high, max(low, v))
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a