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, and switches only when
that buffer exists.

One new test drives a running UI on a simulation screen with Alt and
1, then a typed line and Enter. Another holds the first line and
checks that the second waits for it.

Model: opus-5-5
This commit was merged in pull request #227.
This commit is contained in:
2026-11-02 01:08:43 +01:00
parent 967930f9bc
commit fe734af375
3 changed files with 177 additions and 24 deletions
+10 -7
View File
@@ -610,21 +610,24 @@ func (a *App) cmdWindow(args string) {
_, _ = fmt.Sscanf(args, "%d", &bufIndex) _, _ = fmt.Sscanf(args, "%d", &bufIndex)
a.ui.SwitchBuffer(bufIndex)
a.mu.Lock() a.mu.Lock()
nick := a.nick nick := a.nick
a.mu.Unlock() a.mu.Unlock()
if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { // Read the name before switching. A message from the server can
buf := a.ui.buffers[bufIndex] // add a buffer at any time, but only at the end, so bufIndex
if buf.Name != "(status)" { // still names this buffer when SwitchBuffer runs.
name, found := a.ui.BufferName(bufIndex)
if found {
a.ui.SwitchBuffer(bufIndex)
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()
+112 -4
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,14 @@ 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
// holdTime is how long TestLinesKeepOrder holds the first line,
// which gives the second line time to start too early.
const holdTime = 200 * time.Millisecond
func TestMemberPrefix(t *testing.T) { func TestMemberPrefix(t *testing.T) {
t.Parallel() t.Parallel()
@@ -251,7 +260,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 +269,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 +286,68 @@ 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, 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)
}
// TestLinesKeepOrder holds the handling of a first line until a
// second line has been passed in. It fails if the second line is
// handled while the first is still held.
func TestLinesKeepOrder(t *testing.T) {
t.Parallel()
const firstLine, secondLine = "one", "two"
ui := NewUI()
handled := make(chan string)
release := make(chan struct{})
ui.OnInput(func(text string) {
if text == firstLine {
<-release
}
handled <- text
})
ui.callOnInput(firstLine)
ui.callOnInput(secondLine)
select {
case got := <-handled:
t.Fatalf("handled %q while the first line was held", got)
case <-time.After(holdTime):
}
close(release)
waitHandled(t, handled, firstLine)
waitHandled(t, handled, secondLine)
}
// 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 +430,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 +481,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 {
case err := <-done:
if err != nil { if err != nil {
t.Errorf("run ui: %v", err) 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.
} }