Files
go-ts/cmd/tui/model.go

477 lines
10 KiB
Go
Raw Normal View History

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...)
}