package main 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{ app: tview.NewApplication(), buffers: []*Buffer{ {Name: "(status)", Lines: nil}, }, } // Message area. ui.messages = tview.NewTextView(). SetDynamicColors(true). SetScrollable(true). SetWordWrap(true). SetChangedFunc(func() { ui.app.Draw() }) ui.messages.SetBorder(false) // Status bar. ui.statusBar = tview.NewTextView(). SetDynamicColors(true) ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetTextColor(tcell.ColorWhite) // Input field. ui.input = tview.NewInputField(). SetFieldBackgroundColor(tcell.ColorBlack). SetFieldTextColor(tcell.ColorWhite) ui.input.SetDoneFunc(func(key tcell.Key) { if key == tcell.KeyEnter { text := ui.input.GetText() if text == "" { return } ui.input.SetText("") if ui.onInput != nil { ui.onInput(text) } } }) // Capture Alt+N for window switching. ui.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Modifiers()&tcell.ModAlt != 0 { r := event.Rune() if r >= '0' && r <= '9' { idx := int(r - '0') ui.SwitchBuffer(idx) return nil } } return event }) // Layout: messages on top, status bar, input at bottom. 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 { return ui.app.Run() } // 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 string, line string) { ui.app.QueueUpdateDraw(func() { buf := ui.getOrCreateBuffer(bufferName) buf.Lines = append(buf.Lines, line) // Mark unread if not currently viewing this buffer. if ui.buffers[ui.currentBuffer] != buf { buf.Unread++ ui.refreshStatus() } // If viewing this buffer, append to display. if ui.buffers[ui.currentBuffer] == buf { fmt.Fprintln(ui.messages, line) } }) } // AddStatus adds a line to the status buffer (buffer 0). func (ui *UI) AddStatus(line string) { ts := time.Now().Format("15:04") ui.AddLine("(status)", fmt.Sprintf("[gray]%s[white] %s", ts, line)) } // SwitchBuffer switches to the buffer at index n. func (ui *UI) SwitchBuffer(n int) { ui.app.QueueUpdateDraw(func() { if n < 0 || n >= len(ui.buffers) { return } ui.currentBuffer = n buf := ui.buffers[n] buf.Unread = 0 ui.messages.Clear() for _, line := range buf.Lines { fmt.Fprintln(ui.messages, line) } ui.messages.ScrollToEnd() ui.refreshStatus() }) } // SwitchToBuffer switches to the named buffer, creating it 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.refreshStatus() }) } // SetStatus updates the status bar text. func (ui *UI) SetStatus(nick, target, connStatus string) { ui.app.QueueUpdateDraw(func() { ui.refreshStatusWith(nick, target, connStatus) }) } func (ui *UI) refreshStatus() { // Will be called from the main goroutine via QueueUpdateDraw parent. // Rebuild status from app state — caller must provide context. } func (ui *UI) refreshStatusWith(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} ui.buffers = append(ui.buffers, buf) return buf } // BufferCount returns the number of buffers. func (ui *UI) BufferCount() int { return len(ui.buffers) } // BufferIndex returns the index of a named buffer, or -1. func (ui *UI) BufferIndex(name string) int { for i, buf := range ui.buffers { if buf.Name == name { return i } } return -1 }