check / check (push) Waiting to run
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
353 lines
6.9 KiB
Go
353 lines
6.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/rivo/tview"
|
|
)
|
|
|
|
// Buffer holds messages for a channel/DM/status window.
|
|
type Buffer struct {
|
|
Name string
|
|
Lines []string
|
|
Unread int
|
|
}
|
|
|
|
// 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 {
|
|
app *tview.Application
|
|
messages *tview.TextView
|
|
statusBar *tview.TextView
|
|
input *tview.InputField
|
|
layout *tview.Flex
|
|
|
|
buffers []*Buffer
|
|
currentBuffer int
|
|
|
|
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.
|
|
func NewUI() *UI {
|
|
ui := &UI{ //nolint:exhaustruct,varnamelen // fields set below; ui is idiomatic
|
|
app: tview.NewApplication(),
|
|
buffers: []*Buffer{
|
|
{Name: "(status)", Lines: nil, Unread: 0},
|
|
},
|
|
}
|
|
|
|
ui.initMessages()
|
|
ui.initStatusBar()
|
|
ui.initInput()
|
|
ui.initKeyCapture()
|
|
|
|
ui.layout = tview.NewFlex().
|
|
SetDirection(tview.FlexRow).
|
|
AddItem(ui.messages, 0, 1, false).
|
|
AddItem(ui.statusBar, 1, 0, false).
|
|
AddItem(ui.input, 1, 0, true)
|
|
|
|
ui.app.SetRoot(ui.layout, true)
|
|
ui.app.SetFocus(ui.input)
|
|
|
|
return ui
|
|
}
|
|
|
|
// Run starts the UI event loop (blocks).
|
|
func (ui *UI) Run() error {
|
|
err := ui.app.Run()
|
|
if err != nil {
|
|
return fmt.Errorf("run ui: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the UI.
|
|
func (ui *UI) Stop() {
|
|
ui.app.Stop()
|
|
}
|
|
|
|
// OnInput sets the callback for user input.
|
|
func (ui *UI) OnInput(fn func(string)) {
|
|
ui.onInput = fn
|
|
}
|
|
|
|
// AddLine adds a line to the specified buffer.
|
|
func (ui *UI) AddLine(bufferName, line string) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
buf := ui.getOrCreateBuffer(bufferName)
|
|
buf.Lines = append(buf.Lines, line)
|
|
|
|
cur := ui.buffers[ui.currentBuffer]
|
|
if cur != buf {
|
|
buf.Unread++
|
|
|
|
ui.refreshStatusBar()
|
|
}
|
|
|
|
if cur == buf {
|
|
_, _ = fmt.Fprintln(ui.messages, line)
|
|
}
|
|
})
|
|
}
|
|
|
|
// AddLineAsRead adds a line to the specified buffer, as
|
|
// AddLine does, but does not mark the buffer unread.
|
|
func (ui *UI) AddLineAsRead(bufferName, line string) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
buf := ui.getOrCreateBuffer(bufferName)
|
|
buf.Lines = append(buf.Lines, line)
|
|
|
|
if ui.buffers[ui.currentBuffer] == buf {
|
|
_, _ = fmt.Fprintln(ui.messages, line)
|
|
}
|
|
})
|
|
}
|
|
|
|
// AddStatus adds a line to the status buffer.
|
|
func (ui *UI) AddStatus(line string) {
|
|
ts := time.Now().Format("15:04")
|
|
ui.AddLine(
|
|
"(status)",
|
|
"[gray]"+ts+"[white] "+line,
|
|
)
|
|
}
|
|
|
|
// SwitchBuffer switches to the buffer at index n.
|
|
func (ui *UI) SwitchBuffer(bufIndex int) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
if bufIndex < 0 || bufIndex >= len(ui.buffers) {
|
|
return
|
|
}
|
|
|
|
ui.currentBuffer = bufIndex
|
|
|
|
buf := ui.buffers[bufIndex]
|
|
buf.Unread = 0
|
|
|
|
ui.messages.Clear()
|
|
|
|
for _, line := range buf.Lines {
|
|
_, _ = fmt.Fprintln(ui.messages, line)
|
|
}
|
|
|
|
ui.messages.ScrollToEnd()
|
|
ui.refreshStatusBar()
|
|
})
|
|
}
|
|
|
|
// SwitchToBuffer switches to named buffer, creating if
|
|
// needed.
|
|
func (ui *UI) SwitchToBuffer(name string) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
buf := ui.getOrCreateBuffer(name)
|
|
|
|
for i, b := range ui.buffers {
|
|
if b == buf {
|
|
ui.currentBuffer = i
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
buf.Unread = 0
|
|
|
|
ui.messages.Clear()
|
|
|
|
for _, line := range buf.Lines {
|
|
_, _ = fmt.Fprintln(ui.messages, line)
|
|
}
|
|
|
|
ui.messages.ScrollToEnd()
|
|
ui.refreshStatusBar()
|
|
})
|
|
}
|
|
|
|
// SetStatus updates the status bar text.
|
|
func (ui *UI) SetStatus(
|
|
nick, target, connStatus string,
|
|
) {
|
|
ui.app.QueueUpdateDraw(func() {
|
|
ui.renderStatusBar(nick, target, connStatus)
|
|
})
|
|
}
|
|
|
|
// BufferName returns the name of the buffer at index bufIndex,
|
|
// and whether there is such a buffer.
|
|
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.
|
|
func (ui *UI) BufferIndex(name string) int {
|
|
for i, buf := range ui.buffers {
|
|
if buf.Name == name {
|
|
return i
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
func (ui *UI) initMessages() {
|
|
ui.messages = tview.NewTextView().
|
|
SetDynamicColors(true).
|
|
SetScrollable(true).
|
|
SetWordWrap(true).
|
|
SetChangedFunc(func() {
|
|
ui.app.Draw()
|
|
})
|
|
ui.messages.SetBorder(false)
|
|
}
|
|
|
|
func (ui *UI) initStatusBar() {
|
|
ui.statusBar = tview.NewTextView().
|
|
SetDynamicColors(true)
|
|
ui.statusBar.SetBackgroundColor(tcell.ColorNavy)
|
|
ui.statusBar.SetTextColor(tcell.ColorWhite)
|
|
}
|
|
|
|
func (ui *UI) initInput() {
|
|
ui.input = tview.NewInputField().
|
|
SetFieldBackgroundColor(tcell.ColorBlack).
|
|
SetFieldTextColor(tcell.ColorWhite)
|
|
|
|
ui.input.SetDoneFunc(func(key tcell.Key) {
|
|
if key != tcell.KeyEnter {
|
|
return
|
|
}
|
|
|
|
text := ui.input.GetText()
|
|
if text == "" {
|
|
return
|
|
}
|
|
|
|
ui.input.SetText("")
|
|
ui.callOnInput(text)
|
|
})
|
|
}
|
|
|
|
func (ui *UI) initKeyCapture() {
|
|
ui.app.SetInputCapture(
|
|
func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
return event
|
|
}
|
|
|
|
// Alt and a digit runs /window with that digit.
|
|
// SwitchBuffer alone would leave the target as it was.
|
|
r := event.Rune()
|
|
if r >= '0' && r <= '9' {
|
|
ui.callOnInput("/window " + string(r))
|
|
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
},
|
|
)
|
|
}
|
|
|
|
// 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() {
|
|
// Placeholder; full refresh needs nick/target context.
|
|
}
|
|
|
|
func (ui *UI) renderStatusBar(
|
|
nick, target, connStatus string,
|
|
) {
|
|
var unreadParts []string
|
|
|
|
for i, buf := range ui.buffers {
|
|
if buf.Unread > 0 {
|
|
unreadParts = append(unreadParts,
|
|
fmt.Sprintf(
|
|
"%d:%s(%d)",
|
|
i, buf.Name, buf.Unread,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
unread := ""
|
|
if len(unreadParts) > 0 {
|
|
unread = " [Act: " +
|
|
strings.Join(unreadParts, ",") + "]"
|
|
}
|
|
|
|
bufInfo := fmt.Sprintf(
|
|
"[%d:%s]",
|
|
ui.currentBuffer,
|
|
ui.buffers[ui.currentBuffer].Name,
|
|
)
|
|
|
|
ui.statusBar.Clear()
|
|
|
|
_, _ = fmt.Fprintf(ui.statusBar,
|
|
" [%s] %s %s %s%s",
|
|
connStatus, nick, bufInfo, target, unread,
|
|
)
|
|
}
|
|
|
|
func (ui *UI) getOrCreateBuffer(name string) *Buffer {
|
|
for _, buf := range ui.buffers {
|
|
if buf.Name == name {
|
|
return buf
|
|
}
|
|
}
|
|
|
|
buf := &Buffer{Name: name, Lines: nil, Unread: 0}
|
|
ui.buffers = append(ui.buffers, buf)
|
|
|
|
return buf
|
|
}
|