Files
neoirc/internal/cli/ui.go
T
clawbot 2b1a49e59b
check / check (push) Waiting to run
Redraw the status bar when a line arrives in another window (closes #232)
A line that arrives in a window that is not shown is counted as
unread, but refreshStatusBar did nothing, so the window joined the
status bar's unread list only at the next SetStatus. UI now keeps
the nick, target and connection state last passed to SetStatus, and
refreshStatusBar redraws the status bar with them.

Model: opus-5-5
2026-11-02 06:03:53 +01:00

367 lines
7.3 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
// The nick, target and connection state last passed to
// SetStatus.
nick string
target string
connStatus 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.
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.nick = nick
ui.target = target
ui.connStatus = connStatus
ui.refreshStatusBar()
})
}
// 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() {
// Colour tags stay off. With them on, tview reads text in
// square brackets, such as "[connected]" or part of a nick,
// as a tag and drops it.
ui.statusBar = tview.NewTextView()
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)
}()
}
// refreshStatusBar redraws the status bar with the nick, target
// and connection state last passed to SetStatus.
func (ui *UI) refreshStatusBar() {
ui.renderStatusBar(ui.nick, ui.target, ui.connStatus)
}
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
}