1 Commits
Author SHA1 Message Date
sneak 2710e700e8 Keep neoirc-cli responding when a key or a typed line updates the screen (closes #223)
check / check (push) Canceled after 0s
The Enter handler of the input line and the Alt and digit key handler
ran the typed line on the tview event loop. Every UI method that
changes the screen waits for that loop, so the client stopped
responding. Each line now runs on its own goroutine, which first waits
for the line before it, so lines keep the order they were typed in.

/window now runs off the event loop. It reads the buffer name through
the new BufferName, which runs on the loop, instead of reading the
buffer list directly.

A new test drives a running UI on a simulation screen with Alt and 1,
then a typed line and Enter.

Model: opus-5-5
2026-11-01 23:23:11 +00:00
3 changed files with 134 additions and 22 deletions
+5 -5
View File
@@ -616,15 +616,15 @@ func (a *App) cmdWindow(args string) {
nick := a.nick nick := a.nick
a.mu.Unlock() a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { name, found := a.ui.BufferName(bufIndex)
buf := a.ui.buffers[bufIndex] if found {
if buf.Name != "(status)" { if name != "(status)" {
a.mu.Lock() a.mu.Lock()
a.target = buf.Name a.target = name
a.mu.Unlock() a.mu.Unlock()
a.ui.SetStatus( a.ui.SetStatus(
nick, buf.Name, "connected", nick, name, "connected",
) )
} else { } else {
a.mu.Lock() a.mu.Lock()
+76 -6
View File
@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
api "sneak.berlin/go/neoirc/internal/cli/api" api "sneak.berlin/go/neoirc/internal/cli/api"
@@ -20,6 +21,10 @@ const (
testText = "hello" testText = "hello"
) )
// eventLoopTimeout is how long a test waits for a typed line to
// be handled, or for the UI to stop, before it fails.
const eventLoopTimeout = 5 * time.Second
func TestMemberPrefix(t *testing.T) { func TestMemberPrefix(t *testing.T) {
t.Parallel() t.Parallel()
@@ -251,7 +256,7 @@ func TestAltDigit(t *testing.T) {
t.Parallel() t.Parallel()
app := &App{ui: NewUI(), nick: testNick, target: testOtherNick} app := &App{ui: NewUI(), nick: testNick, target: testOtherNick}
app.ui.OnInput(app.handleInput) handled := handledLines(app)
runUI(t, app.ui) runUI(t, app.ui)
// Window 1 is the channel. Window 2, the one shown, is // Window 1 is the channel. Window 2, the one shown, is
@@ -260,7 +265,8 @@ func TestAltDigit(t *testing.T) {
app.ui.SwitchToBuffer(testOtherNick) app.ui.SwitchToBuffer(testOtherNick)
key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt) key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt)
app.ui.app.GetInputCapture()(key) app.ui.app.QueueEvent(key)
waitHandled(t, handled, "/window "+string(test.digit))
var shown string var shown string
@@ -276,6 +282,34 @@ func TestAltDigit(t *testing.T) {
} }
} }
// TestEventLoopKeepsRunning sends Alt and 1, then a typed line and
// Enter, to a running UI. It checks that both are handled, in that
// order, so the event loop did not stop on either.
func TestEventLoopKeepsRunning(t *testing.T) {
t.Parallel()
app := &App{ui: NewUI(), nick: testNick}
handled := handledLines(app)
runUI(t, app.ui)
// Window 1 is the channel, and window 0, the status window, is
// shown. The typed line adds a line to the status window, as
// the client is not connected.
app.ui.SwitchToBuffer(testChannel)
app.ui.SwitchBuffer(0)
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '1', tcell.ModAlt))
for _, r := range testText {
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
}
app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone))
waitHandled(t, handled, "/window 1")
waitHandled(t, handled, testText)
}
// TestOwnEventFromOtherClient checks that a message, notice, // TestOwnEventFromOtherClient checks that a message, notice,
// join or part that the user's session makes from another // join or part that the user's session makes from another
// client shows as one line in the buffer it belongs to. // client shows as one line in the buffer it belongs to.
@@ -358,6 +392,36 @@ func bufferState(ui *UI, name string) (int, int) {
return lines, unread return lines, unread
} }
// handledLines makes app.ui pass each typed line to
// app.handleInput, as Run does, and returns a channel that
// receives each line once it has been handled.
func handledLines(app *App) <-chan string {
handled := make(chan string)
app.ui.OnInput(func(text string) {
app.handleInput(text)
handled <- text
})
return handled
}
// waitHandled fails the test unless want is the next line handled,
// within eventLoopTimeout.
func waitHandled(t *testing.T, handled <-chan string, want string) {
t.Helper()
select {
case got := <-handled:
if got != want {
t.Fatalf("handled %q, want %q", got, want)
}
case <-time.After(eventLoopTimeout):
t.Fatalf("%q was not handled within %v", want, eventLoopTimeout)
}
}
// newServer starts a server that answers every request with // newServer starts a server that answers every request with
// body until the test ends. // body until the test ends.
func newServer(t *testing.T, body string) *httptest.Server { func newServer(t *testing.T, body string) *httptest.Server {
@@ -379,16 +443,22 @@ func runUI(t *testing.T, ui *UI) {
ui.app.SetScreen(tcell.NewSimulationScreen("")) ui.app.SetScreen(tcell.NewSimulationScreen(""))
done := make(chan error) // One slot, so that Run's goroutine can end even after the
// cleanup below has stopped waiting for it.
done := make(chan error, 1)
go func() { done <- ui.Run() }() go func() { done <- ui.Run() }()
t.Cleanup(func() { t.Cleanup(func() {
ui.Stop() ui.Stop()
err := <-done select {
if err != nil { case err := <-done:
t.Errorf("run ui: %v", err) if err != nil {
t.Errorf("run ui: %v", err)
}
case <-time.After(eventLoopTimeout):
t.Error("the UI did not stop: its event loop stopped responding")
} }
}) })
} }
+53 -11
View File
@@ -16,7 +16,10 @@ type Buffer struct {
Unread int Unread int
} }
// UI manages the terminal interface. // UI manages the terminal interface. Most of its methods wait for
// the tview event loop to run their update. Code that runs on that
// loop, such as a key handler, must not call them: it would wait
// for itself.
type UI struct { type UI struct {
app *tview.Application app *tview.Application
messages *tview.TextView messages *tview.TextView
@@ -28,6 +31,10 @@ type UI struct {
currentBuffer int currentBuffer int
onInput func(string) onInput func(string)
// inputDone is closed once the line last passed to onInput
// has been handled. Only callOnInput, on the event loop, uses
// it.
inputDone chan struct{}
} }
// NewUI creates the tview-based IRC-like UI. // NewUI creates the tview-based IRC-like UI.
@@ -176,9 +183,24 @@ func (ui *UI) SetStatus(
}) })
} }
// BufferCount returns the number of buffers. // BufferName returns the name of the buffer at index bufIndex,
func (ui *UI) BufferCount() int { // and whether there is such a buffer.
return len(ui.buffers) func (ui *UI) BufferName(bufIndex int) (string, bool) {
var (
name string
found bool
)
ui.app.QueueUpdate(func() {
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
return
}
name = ui.buffers[bufIndex].Name
found = true
})
return name, found
} }
// BufferIndex returns the index of a named buffer. // BufferIndex returns the index of a named buffer.
@@ -226,10 +248,7 @@ func (ui *UI) initInput() {
} }
ui.input.SetText("") ui.input.SetText("")
ui.callOnInput(text)
if ui.onInput != nil {
ui.onInput(text)
}
}) })
} }
@@ -244,9 +263,7 @@ func (ui *UI) initKeyCapture() {
// SwitchBuffer alone would leave the target as it was. // SwitchBuffer alone would leave the target as it was.
r := event.Rune() r := event.Rune()
if r >= '0' && r <= '9' { if r >= '0' && r <= '9' {
if ui.onInput != nil { ui.callOnInput("/window " + string(r))
ui.onInput("/window " + string(r))
}
return nil return nil
} }
@@ -256,6 +273,31 @@ func (ui *UI) initKeyCapture() {
) )
} }
// callOnInput passes text to the input callback on a new
// goroutine. The key handlers call it on the event loop, and the
// callback waits for that loop to update the screen, so they must
// not wait for the callback. The goroutine first waits for the
// line before it to be handled, which keeps lines in the order
// they were typed.
func (ui *UI) callOnInput(text string) {
if ui.onInput == nil {
return
}
previous := ui.inputDone
done := make(chan struct{})
ui.inputDone = done
go func() {
if previous != nil {
<-previous
}
ui.onInput(text)
close(done)
}()
}
func (ui *UI) refreshStatusBar() { func (ui *UI) refreshStatusBar() {
// Placeholder; full refresh needs nick/target context. // Placeholder; full refresh needs nick/target context.
} }