diff --git a/fix.txt b/fix.txt index bedf5e9..a82bf08 100644 --- a/fix.txt +++ b/fix.txt @@ -238,7 +238,7 @@ Priority 3: configuration, UI, and binding hardening fails, hiding startup failure. Return that error. Also stop/join the PollEvent goroutine on UI shutdown and make Close nonblocking/idempotent. -31. Text UI is not Unicode-safe and timestamp parsing is fragile +[x] 31. Text UI is not Unicode-safe and timestamp parsing is fragile Files: uiterm/textbox.go, uiterm/textview.go Textbox cursor positions are byte offsets but editing/display iterates runes, so non-ASCII input can be split into invalid UTF-8. Textview assumes diff --git a/uiterm/textbox.go b/uiterm/textbox.go index 20e71e1..b35bd08 100644 --- a/uiterm/textbox.go +++ b/uiterm/textbox.go @@ -2,7 +2,7 @@ package uiterm import ( "strings" - // "unicode/utf8" + "unicode/utf8" "github.com/nsf/termbox-go" ) @@ -41,6 +41,9 @@ func (t *Textbox) uiSetBounds(x0, y0, x1, y1 int) { } func (t *Textbox) uiDraw() { + if t.ui == nil { + return + } t.ui.beginDraw() defer t.ui.endDraw() @@ -51,6 +54,9 @@ func (t *Textbox) uiDraw() { if t.pos > len(t.Text) { t.pos = len(t.Text) } + for t.pos > 0 && t.pos < len(t.Text) && !utf8.RuneStart(t.Text[t.pos]) { + t.pos-- + } for y := t.y0; y < t.y1; y++ { for x := t.x0; x < t.x1; x++ { var chr rune @@ -95,10 +101,16 @@ func (t *Textbox) uiKeyEvent(key Key) { t.pos = len(t.Text) redraw = true case KeyArrowLeft: - t.pos -= 1 + if t.pos > 0 { + _, size := utf8.DecodeLastRuneInString(t.Text[:t.pos]) + t.pos -= size + } redraw = true case KeyArrowRight: - t.pos += 1 + if t.pos < len(t.Text) { + _, size := utf8.DecodeRuneInString(t.Text[t.pos:]) + t.pos += size + } redraw = true case KeyCtrlC: t.Text = "" @@ -119,12 +131,12 @@ func (t *Textbox) uiKeyEvent(key Key) { redraw = t.handleHistoryKey(key) case KeySpace: t.uiCharacterEvent(' ') - case KeyBackspace: - case KeyBackspace2: + case KeyBackspace, KeyBackspace2: if len(t.Text) > 0 { if t.pos > 0 { - t.Text = t.Text[:t.pos-1] + t.Text[t.pos:] - t.pos -= 1 + _, size := utf8.DecodeLastRuneInString(t.Text[:t.pos]) + t.Text = t.Text[:t.pos-size] + t.Text[t.pos:] + t.pos -= size } } // if r, size := utf8.DecodeLastRuneInString(t.Text); r != utf8.RuneError { diff --git a/uiterm/textbox_test.go b/uiterm/textbox_test.go index 203678b..d482ffa 100644 --- a/uiterm/textbox_test.go +++ b/uiterm/textbox_test.go @@ -2,6 +2,24 @@ package uiterm import "testing" +// Regression: byte-based cursor movement split UTF-8 input, producing invalid +// text when deleting or inserting beside a non-ASCII character. +func TestTextboxEditsAtRuneBoundaries(t *testing.T) { + textbox := Textbox{Text: "aé", pos: len("aé")} + textbox.uiKeyEvent(KeyArrowLeft) + if textbox.pos != 1 { + t.Fatalf("cursor = %d, want rune boundary 1", textbox.pos) + } + textbox.uiKeyEvent(KeyBackspace) + if textbox.Text != "é" || textbox.pos != 0 { + t.Fatalf("after delete: %q at %d", textbox.Text, textbox.pos) + } + textbox.uiCharacterEvent('ß') + if textbox.Text != "ßé" { + t.Fatalf("insert produced %q", textbox.Text) + } +} + func TestTextboxHistoryNavigatesSubmittedText(t *testing.T) { t.Parallel()