diff options
author | Hubert Hirtz <hubert@hirtzfr.eu> | 2020-08-02 15:59:27 +0200 |
---|---|---|
committer | Hubert Hirtz <hubert@hirtzfr.eu> | 2020-08-02 15:59:27 +0200 |
commit | 22f5b8292d83be4c1bf744cf51bd5e44980cb29b (patch) | |
tree | b3a9c419f68d0bb45a6a73efabd26a9256bfe97e /ui | |
parent | Show 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.go | 151 | ||||
-rw-r--r-- | ui/editor_test.go | 107 | ||||
-rw-r--r-- | ui/ui.go | 97 |
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) +} @@ -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() |