check / check (push) Canceled after 0s
The message window has colour tags on, so tview read square-bracket text in nicks, channel names, reasons, topics and message text as tags. It dropped that text or changed the colour of the rest of the line. A new escape in internal/cli/ui.go writes each "[" as "[[:]", and app.go applies it to every part of a line that comes from the user or the server. tview.Escape is not used: it misses bracket pairs holding symbols such as "|", and tags with a URL. Lines for other server commands now show the command, such as "[MODE]", which tview had read as a tag. Model: opus-5-5
377 lines
7.7 KiB
Go
377 lines
7.7 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)
|
|
}
|
|
|
|
// escape returns text so that the message window shows it exactly
|
|
// as written. It writes each "[" as "[[:]": a "[" followed by
|
|
// another "[" never starts a tag, and the tag "[:]" changes
|
|
// nothing. tview.Escape is not enough: it does not escape a bracket
|
|
// pair holding a symbol such as "|" or "^", and nothing it adds
|
|
// stops a tag with a URL, such as "[:::y|z]".
|
|
func escape(text string) string {
|
|
return strings.ReplaceAll(text, "[", "[[:]")
|
|
}
|
|
|
|
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
|
|
}
|