check / check (push) Waiting to run
neoirc-cli wrote its own line when it sent a message, joined or parted, and ignored the server's event for its own nick, except a JOIN, so a join showed twice and what the session did from another client never showed. The HTTP API queues these events to every client of the session, so the CLI now writes no line of its own when it sends and shows the event as it shows anyone else's. A direct message the user sent goes in the recipient's buffer. The user's own part does not mark the channel unread, as the user has just left it. Model: opus-5-5
308 lines
5.7 KiB
Go
308 lines
5.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.
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
})
|
|
}
|
|
|
|
// BufferCount returns the number of buffers.
|
|
func (ui *UI) BufferCount() int {
|
|
return len(ui.buffers)
|
|
}
|
|
|
|
// 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("")
|
|
|
|
if ui.onInput != nil {
|
|
ui.onInput(text)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (ui *UI) initKeyCapture() {
|
|
ui.app.SetInputCapture(
|
|
func(event *tcell.EventKey) *tcell.EventKey {
|
|
if event.Modifiers()&tcell.ModAlt == 0 {
|
|
return event
|
|
}
|
|
|
|
r := event.Rune()
|
|
if r >= '0' && r <= '9' {
|
|
idx := int(r - '0')
|
|
ui.SwitchBuffer(idx)
|
|
|
|
return nil
|
|
}
|
|
|
|
return event
|
|
},
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|