summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorHubert Hirtz <hubert@hirtzfr.eu>2020-08-02 15:59:27 +0200
committerHubert Hirtz <hubert@hirtzfr.eu>2020-08-02 15:59:27 +0200
commit22f5b8292d83be4c1bf744cf51bd5e44980cb29b (patch)
treeb3a9c419f68d0bb45a6a73efabd26a9256bfe97e /ui
parentShow incoming NOTICEs (diff)
Improve line editing
Correctly support moving around the text and placing glyphs at any position.
Diffstat (limited to 'ui')
-rw-r--r--ui/editor.go151
-rw-r--r--ui/editor_test.go107
-rw-r--r--ui/ui.go97
3 files changed, 288 insertions, 67 deletions
diff --git a/ui/editor.go b/ui/editor.go
new file mode 100644
index 0000000..c735e48
--- /dev/null
+++ b/ui/editor.go
@@ -0,0 +1,151 @@
+package ui
+
+import (
+ "github.com/gdamore/tcell"
+ "github.com/mattn/go-runewidth"
+)
+
+// editor is the text field where the user writes messages and commands.
+type editor struct {
+ // text contains the written runes. An empty slice means no text is written.
+ text []rune
+
+ // textWidth[i] contains the width of string(text[:i]). Therefore
+ // len(textWidth) is always strictly greater than 0 and textWidth[0] is
+ // always 0.
+ textWidth []int
+
+ // cursorIdx is the index in text of the placement of the cursor, or is
+ // equal to len(text) if the cursor is at the end.
+ cursorIdx int
+
+ // offsetIdx is the number of elements of text that are skipped when
+ // rendering.
+ offsetIdx int
+
+ // width is the width of the screen.
+ width int
+}
+
+func newEditor(width int) editor {
+ return editor{
+ text: []rune{},
+ textWidth: []int{0},
+ width: width,
+ }
+}
+
+func (e *editor) Resize(width int) {
+ if width < e.width {
+ e.cursorIdx = 0
+ e.offsetIdx = 0
+ }
+ e.width = width
+}
+
+func (e *editor) IsCommand() bool {
+ return len(e.text) != 0 && e.text[0] == '/'
+}
+
+func (e *editor) TextLen() int {
+ return len(e.text)
+}
+
+func (e *editor) PutRune(r rune) {
+ e.text = append(e.text, ' ')
+ copy(e.text[e.cursorIdx+1:], e.text[e.cursorIdx:])
+ e.text[e.cursorIdx] = r
+
+ rw := runewidth.RuneWidth(r)
+ tw := e.textWidth[len(e.textWidth)-1]
+ e.textWidth = append(e.textWidth, tw+rw)
+ for i := e.cursorIdx + 1; i < len(e.textWidth); i++ {
+ e.textWidth[i] = rw + e.textWidth[i-1]
+ }
+
+ e.Right()
+}
+
+func (e *editor) RemRune() (ok bool) {
+ ok = 0 < e.cursorIdx
+ if !ok {
+ return
+ }
+
+ // TODO avoid looping twice
+ rw := e.textWidth[e.cursorIdx] - e.textWidth[e.cursorIdx-1]
+ for i := e.cursorIdx; i < len(e.textWidth); i++ {
+ e.textWidth[i] -= rw
+ }
+ copy(e.textWidth[e.cursorIdx:], e.textWidth[e.cursorIdx+1:])
+ e.textWidth = e.textWidth[:len(e.textWidth)-1]
+
+ copy(e.text[e.cursorIdx-1:], e.text[e.cursorIdx:])
+ e.text = e.text[:len(e.text)-1]
+ e.Left()
+ return
+}
+
+func (e *editor) Flush() (content string) {
+ content = string(e.text)
+ e.text = e.text[:0]
+ e.textWidth = e.textWidth[:1]
+ e.cursorIdx = 0
+ e.offsetIdx = 0
+ return
+}
+
+func (e *editor) Right() {
+ if e.cursorIdx == len(e.text) {
+ return
+ }
+ e.cursorIdx++
+ if e.width < e.textWidth[e.cursorIdx]-e.textWidth[e.offsetIdx] {
+ e.offsetIdx += 16
+ max := len(e.text) - 1
+ if max < e.offsetIdx {
+ e.offsetIdx = max
+ }
+ }
+}
+
+func (e *editor) Left() {
+ if e.cursorIdx == 0 {
+ return
+ }
+ e.cursorIdx--
+ if e.cursorIdx <= e.offsetIdx {
+ e.offsetIdx -= 16
+ if e.offsetIdx < 0 {
+ e.offsetIdx = 0
+ }
+ }
+}
+
+func (e *editor) Draw(screen tcell.Screen, y int) {
+ st := tcell.StyleDefault
+
+ x := 0
+ i := e.offsetIdx
+
+ for i < len(e.text) && x < e.width {
+ r := e.text[i]
+ screen.SetContent(x, y, r, nil, st)
+ x += runewidth.RuneWidth(r)
+ i++
+ }
+
+ for x < e.width {
+ screen.SetContent(x, y, ' ', nil, st)
+ x++
+ }
+
+ curStart := e.textWidth[e.cursorIdx] - e.textWidth[e.offsetIdx]
+ curEnd := curStart + 1
+ if e.cursorIdx+1 < len(e.textWidth) {
+ curEnd = e.textWidth[e.cursorIdx+1] - e.textWidth[e.offsetIdx]
+ }
+ for x := curStart; x < curEnd; x++ {
+ screen.ShowCursor(x, y)
+ }
+}
diff --git a/ui/editor_test.go b/ui/editor_test.go
new file mode 100644
index 0000000..9f4e131
--- /dev/null
+++ b/ui/editor_test.go
@@ -0,0 +1,107 @@
+package ui
+
+import "testing"
+
+var hell editor = editor{
+ text: []rune{'h', 'e', 'l', 'l'},
+ textWidth: []int{0, 1, 2, 3, 4},
+ cursorIdx: 4,
+ offsetIdx: 0,
+ width: 5,
+}
+
+func assertEditorEq(t *testing.T, actual, expected editor) {
+ if len(actual.text) != len(expected.text) {
+ t.Errorf("expected text len to be %d, got %d\n", len(expected.text), len(actual.text))
+ } else {
+ for i := 0; i < len(actual.text); i++ {
+ a := actual.text[i]
+ e := expected.text[i]
+
+ if a != e {
+ t.Errorf("expected rune #%d to be '%c', got '%c'\n", i, e, a)
+ }
+ }
+ }
+
+ if len(actual.textWidth) != len(expected.textWidth) {
+ t.Errorf("expected textWidth len to be %d, got %d\n", len(expected.textWidth), len(actual.textWidth))
+ } else {
+ for i := 0; i < len(actual.textWidth); i++ {
+ a := actual.textWidth[i]
+ e := expected.textWidth[i]
+
+ if a != e {
+ t.Errorf("expected width #%d to be %d, got %d\n", i, e, a)
+ }
+ }
+ }
+
+ if actual.cursorIdx != expected.cursorIdx {
+ t.Errorf("expected cursorIdx to be %d, got %d\n", expected.cursorIdx, actual.cursorIdx)
+ }
+
+ if actual.offsetIdx != expected.offsetIdx {
+ t.Errorf("expected offsetIdx to be %d, got %d\n", expected.offsetIdx, actual.offsetIdx)
+ }
+
+ if actual.width != expected.width {
+ t.Errorf("expected width to be %d, got %d\n", expected.width, actual.width)
+ }
+}
+
+func TestOneLetter(t *testing.T) {
+ e := newEditor(5)
+ e.PutRune('h')
+ assertEditorEq(t, e, editor{
+ text: []rune{'h'},
+ textWidth: []int{0, 1},
+ cursorIdx: 1,
+ offsetIdx: 0,
+ width: 5,
+ })
+}
+
+func TestFourLetters(t *testing.T) {
+ e := newEditor(5)
+ e.PutRune('h')
+ e.PutRune('e')
+ e.PutRune('l')
+ e.PutRune('l')
+ assertEditorEq(t, e, hell)
+}
+
+func TestOneLeft(t *testing.T) {
+ e := newEditor(5)
+ e.PutRune('h')
+ e.PutRune('l')
+ e.Left()
+ e.PutRune('e')
+ e.PutRune('l')
+ e.Right()
+ assertEditorEq(t, e, hell)
+}
+
+func TestOneRem(t *testing.T) {
+ e := newEditor(5)
+ e.PutRune('h')
+ e.PutRune('l')
+ e.RemRune()
+ e.PutRune('e')
+ e.PutRune('l')
+ e.PutRune('l')
+ assertEditorEq(t, e, hell)
+}
+
+func TestLeftAndRem(t *testing.T) {
+ e := newEditor(5)
+ e.PutRune('h')
+ e.PutRune('l')
+ e.PutRune('e')
+ e.Left()
+ e.RemRune()
+ e.Right()
+ e.PutRune('l')
+ e.PutRune('l')
+ assertEditorEq(t, e, hell)
+}
diff --git a/ui/ui.go b/ui/ui.go
index 6c841a5..bbf170a 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -17,8 +17,7 @@ type UI struct {
scrollAmt int
scrollAtTop bool
- textInput []rune
- textCursor int
+ e editor
}
func New() (ui *UI, err error) {
@@ -34,7 +33,7 @@ func New() (ui *UI, err error) {
return
}
- _, h := ui.screen.Size()
+ w, h := ui.screen.Size()
ui.screen.Clear()
ui.screen.ShowCursor(0, h-2)
@@ -58,7 +57,7 @@ func New() (ui *UI, err error) {
},
}
- ui.textInput = []rune{}
+ ui.e = newEditor(w)
ui.Resize()
@@ -220,105 +219,69 @@ func (ui *UI) AddHistoryLines(buffer string, lines []Line) {
}
}
-func (ui *UI) Input() string {
- return string(ui.textInput)
+func (ui *UI) InputIsCommand() bool {
+ return ui.e.IsCommand()
}
func (ui *UI) InputLen() int {
- return len(ui.textInput)
+ return ui.e.TextLen()
}
func (ui *UI) InputRune(r rune) {
- ui.textInput = append(ui.textInput, r)
- ui.textCursor++
- ui.drawEditor()
+ ui.e.PutRune(r)
+ _, h := ui.screen.Size()
+ ui.e.Draw(ui.screen, h-2)
+ ui.screen.Show()
}
func (ui *UI) InputRight() {
- if ui.textCursor < len(ui.textInput) {
- ui.textCursor++
- ui.drawEditor()
- }
+ ui.e.Right()
+ _, h := ui.screen.Size()
+ ui.e.Draw(ui.screen, h-2)
+ ui.screen.Show()
}
func (ui *UI) InputLeft() {
- if 0 < ui.textCursor {
- ui.textCursor--
- ui.drawEditor()
- }
+ ui.e.Left()
+ _, h := ui.screen.Size()
+ ui.e.Draw(ui.screen, h-2)
+ ui.screen.Show()
}
func (ui *UI) InputBackspace() (ok bool) {
- ok = 0 < len(ui.textInput)
-
+ ok = ui.e.RemRune()
if ok {
- ui.textInput = ui.textInput[:len(ui.textInput)-1]
- if len(ui.textInput) < ui.textCursor {
- ui.textCursor = len(ui.textInput)
- }
- ui.drawEditor()
+ _, h := ui.screen.Size()
+ ui.e.Draw(ui.screen, h-2)
+ ui.screen.Show()
}
-
return
}
func (ui *UI) InputEnter() (content string) {
- content = string(ui.textInput)
-
- ui.textInput = []rune{}
- ui.textCursor = 0
- ui.drawEditor()
-
+ content = ui.e.Flush()
+ _, h := ui.screen.Size()
+ ui.e.Draw(ui.screen, h-2)
+ ui.screen.Show()
return
}
func (ui *UI) Resize() {
+ w, _ := ui.screen.Size()
+ ui.e.Resize(w)
ui.bufferList.Invalidate()
ui.scrollAmt = 0
ui.draw()
}
func (ui *UI) draw() {
+ _, h := ui.screen.Size()
ui.drawStatus()
- ui.drawEditor()
+ ui.e.Draw(ui.screen, h-2)
ui.drawTyping()
ui.drawBuffer()
}
-func (ui *UI) drawEditor() {
- st := tcell.StyleDefault
- w, h := ui.screen.Size()
- if w == 0 {
- return
- }
-
- s := string(ui.textInput)
- sw := runewidth.StringWidth(s)
-
- x := 0
- y := h - 2
- i := 0
-
- for ; w < sw+1 && i < len(ui.textInput); i++ {
- r := ui.textInput[i]
- rw := runewidth.RuneWidth(r)
- sw -= rw
- }
-
- for ; i < len(ui.textInput); i++ {
- r := ui.textInput[i]
- ui.screen.SetContent(x, y, r, nil, st)
- x += runewidth.RuneWidth(r)
- }
-
- for ; x < w; x++ {
- ui.screen.SetContent(x, y, ' ', nil, st)
- }
-
- ui.screen.ShowCursor(ui.textCursor, y)
- ui.screen.Show()
-}
-
func (ui *UI) drawTyping() {
st := tcell.StyleDefault.Dim(true)
w, h := ui.screen.Size()