Files
BLE_emulator/internal/tui/view.go
2026-02-23 01:35:53 +01:00

298 lines
7.6 KiB
Go

package tui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Styles for the TUI
var (
// Colors
primaryColor = lipgloss.Color("39") // Light blue
secondaryColor = lipgloss.Color("245") // Gray
accentColor = lipgloss.Color("205") // Pink
successColor = lipgloss.Color("46") // Green
warningColor = lipgloss.Color("226") // Yellow
errorColor = lipgloss.Color("196") // Red
// Panel styles
panelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(secondaryColor).
Padding(0, 1)
activePanelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primaryColor).
Padding(0, 1)
// Title styles
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(primaryColor).
MarginBottom(1)
// Button styles
buttonStyle = lipgloss.NewStyle().
Padding(0, 2).
Background(secondaryColor).
Foreground(lipgloss.Color("0"))
activeButtonStyle = lipgloss.NewStyle().
Padding(0, 2).
Background(primaryColor).
Foreground(lipgloss.Color("0")).
Bold(true)
// Slider styles
sliderLabelStyle = lipgloss.NewStyle().
Width(14)
sliderValueStyle = lipgloss.NewStyle().
Width(6).
Align(lipgloss.Right)
// Log level styles
logLevelStyles = map[string]lipgloss.Style{
"INFO": lipgloss.NewStyle().Foreground(successColor),
"CONN": lipgloss.NewStyle().Foreground(primaryColor),
"TX": lipgloss.NewStyle().Foreground(accentColor),
"RX": lipgloss.NewStyle().Foreground(warningColor),
"ERR": lipgloss.NewStyle().Foreground(errorColor),
}
// Status styles
statusOnStyle = lipgloss.NewStyle().
Foreground(successColor).
Bold(true)
statusOffStyle = lipgloss.NewStyle().
Foreground(secondaryColor)
// Help style
helpStyle = lipgloss.NewStyle().
Foreground(secondaryColor).
Italic(true)
)
// View renders the TUI
func (m Model) View() string {
if m.quitting {
return "Goodbye!\n"
}
// Calculate panel widths
leftWidth := m.width/2 - 2
rightWidth := m.width - leftWidth - 6
// Render panels
leftPanel := m.renderControlsPanel(leftWidth)
rightPanel := m.renderLogsPanel(rightWidth)
// Apply panel styles based on focus
var leftStyled, rightStyled string
if m.focusedPanel == PanelControls {
leftStyled = activePanelStyle.Width(leftWidth).Render(leftPanel)
rightStyled = panelStyle.Width(rightWidth).Render(rightPanel)
} else {
leftStyled = panelStyle.Width(leftWidth).Render(leftPanel)
rightStyled = activePanelStyle.Width(rightWidth).Render(rightPanel)
}
// Join panels horizontally
content := lipgloss.JoinHorizontal(lipgloss.Top, leftStyled, rightStyled)
// Add title and help
title := titleStyle.Render("BLE Simulator TUI")
help := helpStyle.Render("↑↓: Navigate ←→: Adjust Enter: Activate Tab: Switch Panel q: Quit")
return lipgloss.JoinVertical(lipgloss.Left, title, content, help)
}
// renderControlsPanel renders the left controls panel
func (m Model) renderControlsPanel(width int) string {
var b strings.Builder
b.WriteString(titleStyle.Render("Controls"))
b.WriteString("\n\n")
// Render interactive controls
for i, ctrl := range m.controls {
isActive := m.focusedPanel == PanelControls && i == m.focusedCtrl
switch ctrl.Type {
case ControlButton:
if isActive {
b.WriteString(activeButtonStyle.Render(ctrl.Name))
} else {
b.WriteString(buttonStyle.Render(ctrl.Name))
}
b.WriteString("\n")
case ControlSlider:
b.WriteString(m.renderSlider(ctrl, isActive, width-4))
b.WriteString("\n")
}
b.WriteString("\n")
}
// Parameter display section
b.WriteString("\n")
b.WriteString(titleStyle.Render("Parameters (R/O)"))
b.WriteString("\n\n")
params := m.state.GetAllParams()
paramOrder := []int{1, 2, 3, 4, 11, 12, 20}
paramNames := map[int]string{
1: "W1 (Min)",
2: "W2 (Max)",
3: "W3 (Pulses)",
4: "W4 (Time)",
11: "W11 (MinPD)",
12: "W12 (MaxPD)",
20: "W20 (Gain)",
}
paramRanges := map[int][2]int{
1: {0, 2000},
2: {0, 4095},
3: {1, 50},
4: {1000, 60000},
11: {5, 300},
12: {5, 300},
20: {0, 255},
}
for _, id := range paramOrder {
val := params[id]
rng := paramRanges[id]
name := paramNames[id]
bar := m.renderProgressBar(val, rng[0], rng[1], 10)
b.WriteString(fmt.Sprintf("%s %s %d\n", sliderLabelStyle.Render(name), bar, val))
}
// NC parameter display
ncValue := "True"
if !m.state.GetNCParam() {
ncValue = "False"
}
b.WriteString(fmt.Sprintf("%s %s\n", sliderLabelStyle.Render("WNC (Tru/Fal)"), ncValue))
// Status section
b.WriteString("\n")
b.WriteString(titleStyle.Render("Status"))
b.WriteString("\n\n")
// Connection status
connCount := m.state.GetConnectedClients()
if connCount > 0 {
b.WriteString(fmt.Sprintf("Connected: %s\n", statusOnStyle.Render(fmt.Sprintf("Yes (%d)", connCount))))
} else {
b.WriteString(fmt.Sprintf("Connected: %s\n", statusOffStyle.Render("No")))
}
// Alarm status
if alarmActive, alarmType := m.state.GetAlarmState(); alarmActive {
b.WriteString(fmt.Sprintf("Alarm: %s\n", statusOnStyle.Render(alarmType)))
} else {
b.WriteString(fmt.Sprintf("Alarm: %s\n", statusOffStyle.Render("OFF")))
}
// Prog mode status
if m.state.GetProgMode() {
b.WriteString(fmt.Sprintf("Prog Mode: %s\n", statusOnStyle.Render("ON")))
} else {
b.WriteString(fmt.Sprintf("Prog Mode: %s\n", statusOffStyle.Render("OFF")))
}
return b.String()
}
// renderSlider renders a slider control
func (m Model) renderSlider(ctrl Control, isActive bool, _ int) string {
value := ctrl.GetValue()
bar := m.renderProgressBar(value, ctrl.Min, ctrl.Max, 15)
label := sliderLabelStyle.Render(ctrl.Name)
valueStr := sliderValueStyle.Render(fmt.Sprintf("%d", value))
var prefix string
if isActive {
prefix = lipgloss.NewStyle().Foreground(primaryColor).Bold(true).Render("> ")
} else {
prefix = " "
}
return fmt.Sprintf("%s%s %s %s", prefix, label, bar, valueStr)
}
// renderProgressBar renders a progress bar
func (m Model) renderProgressBar(value, min, max, barWidth int) string {
if max <= min {
return strings.Repeat("░", barWidth)
}
ratio := float64(value-min) / float64(max-min)
filled := int(ratio * float64(barWidth))
if filled > barWidth {
filled = barWidth
}
if filled < 0 {
filled = 0
}
filledStyle := lipgloss.NewStyle().Foreground(primaryColor)
emptyStyle := lipgloss.NewStyle().Foreground(secondaryColor)
return filledStyle.Render(strings.Repeat("█", filled)) +
emptyStyle.Render(strings.Repeat("░", barWidth-filled))
}
// renderLogsPanel renders the right logs panel
func (m Model) renderLogsPanel(width int) string {
var b strings.Builder
b.WriteString(titleStyle.Render("Logs"))
b.WriteString("\n\n")
entries := m.logBuffer.Entries()
maxVisible := m.height - 10 // Account for borders, title, help
maxVisible = max(maxVisible, 1)
// Calculate which entries to show
start := max(len(entries)-maxVisible-m.logOffset, 0)
end := min(start+maxVisible, len(entries))
for i := start; i < end; i++ {
entry := entries[i]
// Format log entry
levelStyle, ok := logLevelStyles[entry.Level]
if !ok {
levelStyle = lipgloss.NewStyle()
}
timeStr := entry.Time.Format("15:04:05")
levelStr := levelStyle.Render(fmt.Sprintf("[%s]", entry.Level))
// Truncate message if too long
msg := entry.Message
maxMsgLen := max(width-16, 10)
if len(msg) > maxMsgLen {
msg = msg[:maxMsgLen-3] + "..."
}
b.WriteString(fmt.Sprintf("%s %s %s\n", timeStr, levelStr, msg))
}
// Show scroll indicator if needed
if len(entries) > maxVisible {
indicator := fmt.Sprintf("\n[%d/%d entries]", end, len(entries))
b.WriteString(helpStyle.Render(indicator))
}
return b.String()
}