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 }