check / check (push) Waiting to run
The status bar had colour tags turned on, so tview read "[connected]" and "[disconnected]" as tags and dropped them. It dropped all or part of a nick, a target or a window name holding square brackets the same way. The status bar now has colour tags turned off. It wrote none of its own, so every bracket shows as it is. TestStartUp checks that the status bar shows [disconnected] at start-up. TestStatusBar checks [connected], and checks nicks and a /query target that hold brackets. Model: opus-5-5
355 lines
7.0 KiB
Go
355 lines
7.0 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() {
|
|
// 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)
|
|
}()
|
|
}
|
|
|
|
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
|
|
}
|