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, 255}, 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() }