From 7fed25e8d0a71914ddeae5a10b7f9738f6d7aa6e Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 14 Jul 2026 19:08:38 -0400 Subject: [PATCH] Fix chat redraw and input history --- README.md | 1 + uiterm/textbox.go | 73 +++++++++++++++++++++++++++++++- uiterm/textbox_test.go | 93 +++++++++++++++++++++++++++++++++++++++++ uiterm/textview.go | 33 ++++++++++++--- uiterm/textview_test.go | 46 ++++++++++++++++++++ 5 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 uiterm/textbox_test.go create mode 100644 uiterm/textview_test.go diff --git a/README.md b/README.md index da01b58..423d226 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ You can see the below keystrokes in your config file. Pressing tab inside the main window switches between the user/channel tree view and the message input box. When in the message input box: * left and right arrow keys move by character +* up and down arrow keys move through previously submitted input * home/end moves to the beginning/end of the text respectively * enter submits the entered message diff --git a/uiterm/textbox.go b/uiterm/textbox.go index f824c6f..20e71e1 100644 --- a/uiterm/textbox.go +++ b/uiterm/textbox.go @@ -17,6 +17,9 @@ type Textbox struct { active bool x0, y0, x1, y1 int pos int + history []string + historyIndex int + historyDraft string } func (t *Textbox) uiInitialize(ui *Ui) { @@ -100,14 +103,20 @@ func (t *Textbox) uiKeyEvent(key Key) { case KeyCtrlC: t.Text = "" t.pos = 0 + t.resetHistoryNavigation() redraw = true case KeyEnter: + text := t.Text if t.Input != nil { - t.Input(t.ui, t, t.Text) + t.Input(t.ui, t, text) } + t.addHistory(text) t.Text = "" t.pos = 0 + t.resetHistoryNavigation() redraw = true + case KeyArrowUp, KeyAltArrowUp, KeyArrowDown, KeyAltArrowDown: + redraw = t.handleHistoryKey(key) case KeySpace: t.uiCharacterEvent(' ') case KeyBackspace: @@ -136,3 +145,65 @@ func (t *Textbox) uiCharacterEvent(chr rune) { t.pos += len(s) t.uiDraw() } + +func (t *Textbox) setText(text string) { + t.Text = text + t.pos = len(text) +} + +func (t *Textbox) addHistory(text string) { + if strings.TrimSpace(text) == "" { + return + } + if len(t.history) > 0 && t.history[len(t.history)-1] == text { + t.historyIndex = len(t.history) + return + } + t.history = append(t.history, text) + t.historyIndex = len(t.history) +} + +func (t *Textbox) handleHistoryKey(key Key) bool { + switch key { + case KeyArrowUp, KeyAltArrowUp: + return t.previousHistory() + case KeyArrowDown, KeyAltArrowDown: + return t.nextHistory() + default: + return false + } +} + +func (t *Textbox) previousHistory() bool { + if len(t.history) == 0 { + return false + } + if t.historyIndex == len(t.history) { + t.historyDraft = t.Text + } + if t.historyIndex > 0 { + t.historyIndex-- + } + t.setText(t.history[t.historyIndex]) + return true +} + +func (t *Textbox) nextHistory() bool { + if len(t.history) == 0 || t.historyIndex == len(t.history) { + return false + } + if t.historyIndex < len(t.history)-1 { + t.historyIndex++ + t.setText(t.history[t.historyIndex]) + return true + } + t.historyIndex = len(t.history) + t.setText(t.historyDraft) + t.historyDraft = "" + return true +} + +func (t *Textbox) resetHistoryNavigation() { + t.historyIndex = len(t.history) + t.historyDraft = "" +} diff --git a/uiterm/textbox_test.go b/uiterm/textbox_test.go new file mode 100644 index 0000000..203678b --- /dev/null +++ b/uiterm/textbox_test.go @@ -0,0 +1,93 @@ +package uiterm + +import "testing" + +func TestTextboxHistoryNavigatesSubmittedText(t *testing.T) { + t.Parallel() + + textbox := Textbox{} + textbox.addHistory("first") + textbox.addHistory("second") + + if !textbox.previousHistory() { + t.Fatal("expected previous history item") + } + if textbox.Text != "second" { + t.Fatalf("expected second history item, got %q", textbox.Text) + } + + if !textbox.previousHistory() { + t.Fatal("expected older history item") + } + if textbox.Text != "first" { + t.Fatalf("expected first history item, got %q", textbox.Text) + } + + if !textbox.nextHistory() { + t.Fatal("expected newer history item") + } + if textbox.Text != "second" { + t.Fatalf("expected second history item after next, got %q", textbox.Text) + } +} + +func TestTextboxHistoryNavigatesAltArrowKeys(t *testing.T) { + t.Parallel() + + textbox := Textbox{} + textbox.addHistory("first") + textbox.addHistory("second") + + if redraw := textbox.handleHistoryKey(KeyAltArrowUp); !redraw { + t.Fatal("expected alt-up to recall history") + } + if textbox.Text != "second" { + t.Fatalf("expected second history item, got %q", textbox.Text) + } + + if redraw := textbox.handleHistoryKey(KeyAltArrowDown); !redraw { + t.Fatal("expected alt-down to move forward through history") + } + if textbox.Text != "" { + t.Fatalf("expected alt-down to restore empty draft, got %q", textbox.Text) + } +} + +func TestTextboxHistoryRestoresDraft(t *testing.T) { + t.Parallel() + + textbox := Textbox{Text: "draft"} + textbox.addHistory("previous") + textbox.setText("draft") + + if !textbox.previousHistory() { + t.Fatal("expected previous history item") + } + if textbox.Text != "previous" { + t.Fatalf("expected previous history item, got %q", textbox.Text) + } + + if !textbox.nextHistory() { + t.Fatal("expected history next to restore draft") + } + if textbox.Text != "draft" { + t.Fatalf("expected draft to be restored, got %q", textbox.Text) + } +} + +func TestTextboxHistorySkipsBlankAndConsecutiveDuplicateEntries(t *testing.T) { + t.Parallel() + + textbox := Textbox{} + textbox.addHistory("") + textbox.addHistory(" ") + textbox.addHistory("repeat") + textbox.addHistory("repeat") + + if len(textbox.history) != 1 { + t.Fatalf("expected one history item, got %d", len(textbox.history)) + } + if textbox.history[0] != "repeat" { + t.Fatalf("expected repeat history item, got %q", textbox.history[0]) + } +} diff --git a/uiterm/textview.go b/uiterm/textview.go index 02fa018..52ccd7e 100644 --- a/uiterm/textview.go +++ b/uiterm/textview.go @@ -77,6 +77,7 @@ func (t *Textview) updateParsedLines() { if t.Lines == nil || width <= 0 { t.parsedLines = nil + t.CurrentLine = 0 return } @@ -108,6 +109,7 @@ func (t *Textview) updateParsedLines() { } } t.parsedLines = parsed + t.clampCurrentLine() } func (t *Textview) AddLine(line string) { @@ -129,12 +131,9 @@ func (t *Textview) uiDraw() { var reader *strings.Reader writeableLines := t.y1 - t.y0 - lineNum := 0 - if writeableLines < len(t.parsedLines) { - lineNum = len(t.parsedLines) - writeableLines - } + lineNum := t.visibleStartLine(writeableLines) //Beep() - for y := t.y0; y < writeableLines; y++ { + for y := t.y0; y < t.y1; y++ { if lineNum < len(t.parsedLines) { reader = strings.NewReader(t.parsedLines[lineNum]) } else { @@ -153,6 +152,30 @@ func (t *Textview) uiDraw() { } //each y } //func +func (t *Textview) visibleStartLine(writeableLines int) int { + if writeableLines <= 0 || len(t.parsedLines) == 0 { + return 0 + } + + bottomStart := len(t.parsedLines) - writeableLines + if bottomStart < 0 { + bottomStart = 0 + } + start := bottomStart - t.CurrentLine + if start < 0 { + return 0 + } + return start +} + +func (t *Textview) clampCurrentLine() { + if len(t.parsedLines) == 0 { + t.CurrentLine = 0 + return + } + t.CurrentLine = bounded(t.CurrentLine, 0, len(t.parsedLines)-1) +} + func (t *Textview) uiKeyEvent(key Key) { } diff --git a/uiterm/textview_test.go b/uiterm/textview_test.go new file mode 100644 index 0000000..d0ed765 --- /dev/null +++ b/uiterm/textview_test.go @@ -0,0 +1,46 @@ +package uiterm + +import "testing" + +func TestTextviewVisibleStartLineShowsBottomWhenNotScrolled(t *testing.T) { + t.Parallel() + + textview := Textview{ + parsedLines: []string{"one", "two", "three", "four"}, + } + + if got := textview.visibleStartLine(3); got != 1 { + t.Fatalf("expected visible lines to start at 1, got %d", got) + } +} + +func TestTextviewVisibleStartLineHonorsScrollOffset(t *testing.T) { + t.Parallel() + + textview := Textview{ + CurrentLine: 2, + parsedLines: []string{"one", "two", "three", "four", "five"}, + } + + if got := textview.visibleStartLine(3); got != 0 { + t.Fatalf("expected scrolled view to start at 0, got %d", got) + } +} + +func TestTextviewUpdateParsedLinesClampsScrollOffset(t *testing.T) { + t.Parallel() + + textview := Textview{ + CurrentLine: 10, + Lines: []string{"one", "two"}, + showTimestamps: true, + x0: 0, + x1: 20, + } + + textview.updateParsedLines() + + if textview.CurrentLine != 1 { + t.Fatalf("expected current line to clamp to 1, got %d", textview.CurrentLine) + } +}