feat: TeamSpeak TUI client with Bubble Tea
- Created TUI client in cmd/tui - Implemented channel navigation and user display - Fixed serverInfo initialization in ts3client - Added real-time log panel in TUI - Ordered channels by ID for stability
This commit is contained in:
476
cmd/tui/model.go
Normal file
476
cmd/tui/model.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go-ts/pkg/ts3client"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Focus indicates which panel has focus
|
||||
type Focus int
|
||||
|
||||
const (
|
||||
FocusChannels Focus = iota
|
||||
FocusChat
|
||||
FocusInput
|
||||
)
|
||||
|
||||
// ChatMessage represents a message in the chat
|
||||
type ChatMessage struct {
|
||||
Time time.Time
|
||||
Sender string
|
||||
Content string
|
||||
}
|
||||
|
||||
// ChannelNode represents a channel in the tree
|
||||
type ChannelNode struct {
|
||||
ID uint64
|
||||
Name string
|
||||
Users []UserNode
|
||||
Expanded bool
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// UserNode represents a user in a channel
|
||||
type UserNode struct {
|
||||
ID uint16
|
||||
Nickname string
|
||||
Talking bool
|
||||
Muted bool
|
||||
IsMe bool
|
||||
}
|
||||
|
||||
// Model is the Bubble Tea model for our TUI
|
||||
type Model struct {
|
||||
// Connection
|
||||
serverAddr string
|
||||
nickname string
|
||||
client *ts3client.Client
|
||||
connected bool
|
||||
connecting bool
|
||||
serverName string
|
||||
selfID uint16
|
||||
ping int
|
||||
|
||||
// UI State
|
||||
focus Focus
|
||||
width, height int
|
||||
channels []ChannelNode
|
||||
selectedIdx int
|
||||
chatMessages []ChatMessage
|
||||
logMessages []string // Debug logs shown in chat panel
|
||||
inputText string
|
||||
inputActive bool
|
||||
|
||||
// Error handling
|
||||
lastError string
|
||||
}
|
||||
|
||||
// addLog adds a message to the log panel
|
||||
func (m *Model) addLog(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
m.logMessages = append(m.logMessages, msg)
|
||||
// Keep last 50 messages
|
||||
if len(m.logMessages) > 50 {
|
||||
m.logMessages = m.logMessages[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// NewModel creates a new TUI model
|
||||
func NewModel(serverAddr, nickname string) *Model {
|
||||
return &Model{
|
||||
serverAddr: serverAddr,
|
||||
nickname: nickname,
|
||||
focus: FocusChannels,
|
||||
channels: []ChannelNode{},
|
||||
chatMessages: []ChatMessage{},
|
||||
logMessages: []string{"Starting..."},
|
||||
}
|
||||
}
|
||||
|
||||
// Init is called when the program starts
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.connectToServer(),
|
||||
tea.SetWindowTitle("TeamSpeak TUI"),
|
||||
)
|
||||
}
|
||||
|
||||
// TS3Event wraps events from the TS3 client
|
||||
type TS3Event struct {
|
||||
Type string
|
||||
Data map[string]any
|
||||
}
|
||||
|
||||
// connectToServer initiates connection to TeamSpeak
|
||||
func (m *Model) connectToServer() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
m.connecting = true
|
||||
|
||||
client := ts3client.New(m.serverAddr, ts3client.Config{
|
||||
Nickname: m.nickname,
|
||||
})
|
||||
|
||||
return ts3ClientMsg{client: client}
|
||||
}
|
||||
}
|
||||
|
||||
type ts3ClientMsg struct {
|
||||
client *ts3client.Client
|
||||
}
|
||||
|
||||
type connectedMsg struct {
|
||||
clientID uint16
|
||||
serverName string
|
||||
}
|
||||
|
||||
type channelListMsg struct {
|
||||
channels []*ts3client.Channel
|
||||
}
|
||||
|
||||
type clientEnterMsg struct {
|
||||
clientID uint16
|
||||
nickname string
|
||||
channelID uint64
|
||||
}
|
||||
|
||||
type clientLeftMsg struct {
|
||||
clientID uint16
|
||||
}
|
||||
|
||||
type chatMsg struct {
|
||||
senderID uint16
|
||||
senderName string
|
||||
message string
|
||||
}
|
||||
|
||||
type errorMsg struct {
|
||||
err string
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
// Update handles messages and user input
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKeyPress(msg)
|
||||
|
||||
case ts3ClientMsg:
|
||||
m.client = msg.client
|
||||
m.connecting = true
|
||||
|
||||
// Connect asynchronously
|
||||
m.client.ConnectAsync()
|
||||
|
||||
// Set up a ticker to poll for state changes
|
||||
return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
|
||||
case tickMsg:
|
||||
// Poll client state
|
||||
if m.client != nil {
|
||||
// Check if connected
|
||||
if !m.connected {
|
||||
info := m.client.GetSelfInfo()
|
||||
serverInfo := m.client.GetServerInfo()
|
||||
m.addLog("Tick: selfInfo=%v, serverInfo=%v", info != nil, serverInfo != nil)
|
||||
if info != nil && serverInfo != nil {
|
||||
m.connected = true
|
||||
m.selfID = info.ClientID
|
||||
m.serverName = serverInfo.Name
|
||||
m.addLog("Connected! ClientID=%d, Server=%s", m.selfID, m.serverName)
|
||||
|
||||
// Get channels
|
||||
channels := m.client.GetChannels()
|
||||
m.addLog("Got %d channels", len(channels))
|
||||
m.updateChannelList(channels)
|
||||
}
|
||||
} else {
|
||||
// Update channel list periodically
|
||||
channels := m.client.GetChannels()
|
||||
if len(channels) != len(m.channels) {
|
||||
m.addLog("Channel update: got %d channels (had %d)", len(channels), len(m.channels))
|
||||
}
|
||||
m.updateChannelList(channels)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue ticking
|
||||
return m, tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
|
||||
case connectedMsg:
|
||||
m.connected = true
|
||||
m.connecting = false
|
||||
m.selfID = msg.clientID
|
||||
m.serverName = msg.serverName
|
||||
return m, nil
|
||||
|
||||
case channelListMsg:
|
||||
m.updateChannelList(msg.channels)
|
||||
return m, nil
|
||||
|
||||
case errorMsg:
|
||||
m.lastError = msg.err
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) updateChannelList(channels []*ts3client.Channel) {
|
||||
// Sort channels by ID for stable ordering
|
||||
sortedChannels := make([]*ts3client.Channel, len(channels))
|
||||
copy(sortedChannels, channels)
|
||||
sort.Slice(sortedChannels, func(i, j int) bool {
|
||||
return sortedChannels[i].ID < sortedChannels[j].ID
|
||||
})
|
||||
|
||||
m.channels = make([]ChannelNode, 0, len(sortedChannels))
|
||||
for _, ch := range sortedChannels {
|
||||
node := ChannelNode{
|
||||
ID: ch.ID,
|
||||
Name: ch.Name,
|
||||
Users: []UserNode{},
|
||||
Expanded: true,
|
||||
}
|
||||
|
||||
// Get users in this channel
|
||||
for _, cl := range m.client.GetClients() {
|
||||
if cl.ChannelID == ch.ID {
|
||||
node.Users = append(node.Users, UserNode{
|
||||
ID: cl.ID,
|
||||
Nickname: cl.Nickname,
|
||||
IsMe: cl.ID == m.selfID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
m.channels = append(m.channels, node)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleKeyPress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Global keys
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
if m.client != nil {
|
||||
m.client.Disconnect()
|
||||
}
|
||||
return m, tea.Quit
|
||||
|
||||
case "tab":
|
||||
// Cycle focus
|
||||
m.focus = (m.focus + 1) % 3
|
||||
return m, nil
|
||||
|
||||
case "m":
|
||||
// Toggle mute (would need to implement)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Focus-specific keys
|
||||
switch m.focus {
|
||||
case FocusChannels:
|
||||
return m.handleChannelKeys(msg)
|
||||
case FocusInput:
|
||||
return m.handleInputKeys(msg)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleChannelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
if m.selectedIdx > 0 {
|
||||
m.selectedIdx--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.selectedIdx < len(m.channels)-1 {
|
||||
m.selectedIdx++
|
||||
}
|
||||
case "enter":
|
||||
// Join selected channel
|
||||
if m.selectedIdx < len(m.channels) && m.client != nil {
|
||||
ch := m.channels[m.selectedIdx]
|
||||
m.client.JoinChannel(ch.ID)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleInputKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if m.inputText != "" && m.client != nil {
|
||||
m.client.SendChannelMessage(m.inputText)
|
||||
m.inputText = ""
|
||||
}
|
||||
case "esc":
|
||||
m.focus = FocusChannels
|
||||
m.inputText = ""
|
||||
case "backspace":
|
||||
if len(m.inputText) > 0 {
|
||||
m.inputText = m.inputText[:len(m.inputText)-1]
|
||||
}
|
||||
default:
|
||||
// Add character to input
|
||||
if len(msg.String()) == 1 {
|
||||
m.inputText += msg.String()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the UI
|
||||
func (m *Model) View() string {
|
||||
if m.width == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Styles
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("57")).
|
||||
Padding(0, 1).
|
||||
Width(m.width)
|
||||
|
||||
channelPanelStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(0, 1).
|
||||
Width(m.width/3 - 2).
|
||||
Height(m.height - 6)
|
||||
|
||||
chatPanelStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(0, 1).
|
||||
Width(m.width*2/3 - 2).
|
||||
Height(m.height - 6)
|
||||
|
||||
inputStyle := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(0, 1).
|
||||
Width(m.width - 4)
|
||||
|
||||
// Header
|
||||
status := "Connecting..."
|
||||
if m.connected {
|
||||
status = fmt.Sprintf("Server: %s │ You: %s (ID: %d)", m.serverName, m.nickname, m.selfID)
|
||||
}
|
||||
header := headerStyle.Render(status)
|
||||
|
||||
// Channel panel
|
||||
channelContent := m.renderChannels()
|
||||
if m.focus == FocusChannels {
|
||||
channelPanelStyle = channelPanelStyle.BorderForeground(lipgloss.Color("212"))
|
||||
}
|
||||
channelPanel := channelPanelStyle.Render(channelContent)
|
||||
|
||||
// Chat panel
|
||||
chatContent := m.renderChat()
|
||||
if m.focus == FocusChat {
|
||||
chatPanelStyle = chatPanelStyle.BorderForeground(lipgloss.Color("212"))
|
||||
}
|
||||
chatPanel := chatPanelStyle.Render(chatContent)
|
||||
|
||||
// Input
|
||||
inputContent := "> " + m.inputText
|
||||
if m.focus == FocusInput {
|
||||
inputStyle = inputStyle.BorderForeground(lipgloss.Color("212"))
|
||||
inputContent += "█"
|
||||
}
|
||||
input := inputStyle.Render(inputContent)
|
||||
|
||||
// Footer help
|
||||
help := lipgloss.NewStyle().Faint(true).Render("↑↓ navigate │ Enter join │ Tab switch │ q quit")
|
||||
|
||||
// Combine panels
|
||||
panels := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, chatPanel)
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
header,
|
||||
panels,
|
||||
input,
|
||||
help,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) renderChannels() string {
|
||||
if len(m.channels) == 0 {
|
||||
return "No channels..."
|
||||
}
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("CHANNELS"))
|
||||
lines = append(lines, "")
|
||||
|
||||
for i, ch := range m.channels {
|
||||
prefix := " "
|
||||
if i == m.selectedIdx {
|
||||
prefix = "► "
|
||||
}
|
||||
|
||||
style := lipgloss.NewStyle()
|
||||
if i == m.selectedIdx {
|
||||
style = style.Bold(true).Foreground(lipgloss.Color("212"))
|
||||
}
|
||||
|
||||
lines = append(lines, style.Render(prefix+ch.Name))
|
||||
|
||||
// Show users in channel
|
||||
for _, user := range ch.Users {
|
||||
userPrefix := " └─ "
|
||||
userStyle := lipgloss.NewStyle().Faint(true)
|
||||
if user.IsMe {
|
||||
userStyle = userStyle.Foreground(lipgloss.Color("82"))
|
||||
userPrefix = " └─ ► "
|
||||
}
|
||||
lines = append(lines, userStyle.Render(userPrefix+user.Nickname))
|
||||
}
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
func (m *Model) renderChat() string {
|
||||
var lines []string
|
||||
lines = append(lines, lipgloss.NewStyle().Bold(true).Render("LOG"))
|
||||
lines = append(lines, "")
|
||||
|
||||
if len(m.logMessages) == 0 {
|
||||
lines = append(lines, lipgloss.NewStyle().Faint(true).Render("No logs yet..."))
|
||||
} else {
|
||||
// Limit to last N messages that fit in the panel
|
||||
maxLines := m.height - 10
|
||||
if maxLines < 5 {
|
||||
maxLines = 5
|
||||
}
|
||||
start := 0
|
||||
if len(m.logMessages) > maxLines {
|
||||
start = len(m.logMessages) - maxLines
|
||||
}
|
||||
for _, msg := range m.logMessages[start:] {
|
||||
lines = append(lines, lipgloss.NewStyle().Faint(true).Render(msg))
|
||||
}
|
||||
}
|
||||
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
Reference in New Issue
Block a user