tui simulation
This commit is contained in:
89
internal/tui/logger.go
Normal file
89
internal/tui/logger.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry represents a single log entry with timestamp and level
|
||||
type LogEntry struct {
|
||||
Time time.Time
|
||||
Level string // INFO, CONN, TX, RX, ERR
|
||||
Message string
|
||||
}
|
||||
|
||||
// LogBuffer is a thread-safe circular buffer for log entries
|
||||
type LogBuffer struct {
|
||||
mu sync.RWMutex
|
||||
entries []LogEntry
|
||||
maxSize int
|
||||
}
|
||||
|
||||
// NewLogBuffer creates a new LogBuffer with the given max size
|
||||
func NewLogBuffer(maxSize int) *LogBuffer {
|
||||
return &LogBuffer{
|
||||
entries: make([]LogEntry, 0, maxSize),
|
||||
maxSize: maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Log adds a new log entry
|
||||
func (l *LogBuffer) Log(level, message string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
entry := LogEntry{
|
||||
Time: time.Now(),
|
||||
Level: level,
|
||||
Message: message,
|
||||
}
|
||||
l.entries = append(l.entries, entry)
|
||||
if len(l.entries) > l.maxSize {
|
||||
l.entries = l.entries[1:]
|
||||
}
|
||||
// No p.Send() - TUI refreshes via tick
|
||||
}
|
||||
|
||||
// Info logs an INFO level message
|
||||
func (l *LogBuffer) Info(format string, args ...any) {
|
||||
l.Log("INFO", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Conn logs a CONN level message
|
||||
func (l *LogBuffer) Conn(format string, args ...any) {
|
||||
l.Log("CONN", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// TX logs a TX level message
|
||||
func (l *LogBuffer) TX(format string, args ...any) {
|
||||
l.Log("TX", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// RX logs an RX level message
|
||||
func (l *LogBuffer) RX(format string, args ...any) {
|
||||
l.Log("RX", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Err logs an ERR level message
|
||||
func (l *LogBuffer) Err(format string, args ...any) {
|
||||
l.Log("ERR", fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// Entries returns a copy of all log entries
|
||||
func (l *LogBuffer) Entries() []LogEntry {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
result := make([]LogEntry, len(l.entries))
|
||||
copy(result, l.entries)
|
||||
return result
|
||||
}
|
||||
|
||||
// Len returns the number of entries
|
||||
func (l *LogBuffer) Len() int {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return len(l.entries)
|
||||
}
|
||||
|
||||
125
internal/tui/model.go
Normal file
125
internal/tui/model.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"ble_simulator/internal/device"
|
||||
)
|
||||
|
||||
// ControlType represents the type of control
|
||||
type ControlType int
|
||||
|
||||
const (
|
||||
ControlButton ControlType = iota
|
||||
ControlSlider
|
||||
)
|
||||
|
||||
// Control represents an interactive control in the TUI
|
||||
type Control struct {
|
||||
Name string
|
||||
Type ControlType
|
||||
Action func() // For buttons
|
||||
GetValue func() int // For sliders
|
||||
SetValue func(int) // For sliders
|
||||
Min int // For sliders
|
||||
Max int // For sliders
|
||||
Step int // For sliders
|
||||
}
|
||||
|
||||
// FocusPanel represents which panel is focused
|
||||
type FocusPanel int
|
||||
|
||||
const (
|
||||
PanelControls FocusPanel = iota
|
||||
PanelLogs
|
||||
)
|
||||
|
||||
// Model is the main Bubble Tea model
|
||||
type Model struct {
|
||||
state *device.DeviceState
|
||||
logBuffer *LogBuffer
|
||||
notifyCh chan string // Channel to send alarm notifications
|
||||
|
||||
controls []Control
|
||||
focusedCtrl int
|
||||
focusedPanel FocusPanel
|
||||
|
||||
logOffset int // Scroll offset for log view
|
||||
|
||||
width int
|
||||
height int
|
||||
|
||||
quitting bool
|
||||
}
|
||||
|
||||
// NewModel creates a new TUI model
|
||||
func NewModel(state *device.DeviceState, logBuffer *LogBuffer, notifyCh chan string) Model {
|
||||
m := Model{
|
||||
state: state,
|
||||
logBuffer: logBuffer,
|
||||
notifyCh: notifyCh,
|
||||
focusedCtrl: 0,
|
||||
focusedPanel: PanelControls,
|
||||
logOffset: 0,
|
||||
width: 80,
|
||||
height: 24,
|
||||
}
|
||||
|
||||
// Setup controls
|
||||
m.controls = []Control{
|
||||
{
|
||||
Name: "Trigger Alarm",
|
||||
Type: ControlButton,
|
||||
Action: func() {
|
||||
state.TriggerAlarm("TRIGGERED")
|
||||
logBuffer.Info("Alarm triggered manually")
|
||||
if notifyCh != nil {
|
||||
select {
|
||||
case notifyCh <- "ALARM: TRIGGERED":
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Trigger Tamper",
|
||||
Type: ControlButton,
|
||||
Action: func() {
|
||||
state.TriggerAlarm("TAMPER")
|
||||
logBuffer.Info("Tamper alarm triggered manually")
|
||||
if notifyCh != nil {
|
||||
select {
|
||||
case notifyCh <- "ALARM: TAMPER":
|
||||
default:
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Reset Alarm",
|
||||
Type: ControlButton,
|
||||
Action: func() {
|
||||
state.ResetAlarm()
|
||||
logBuffer.Info("Alarm reset")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Sensor Value",
|
||||
Type: ControlSlider,
|
||||
GetValue: state.GetSensorValue,
|
||||
SetValue: state.SetSensorValue,
|
||||
Min: 0,
|
||||
Max: 4095,
|
||||
Step: 50,
|
||||
},
|
||||
{
|
||||
Name: "Jitter Range",
|
||||
Type: ControlSlider,
|
||||
GetValue: state.GetJitterRange,
|
||||
SetValue: state.SetJitterRange,
|
||||
Min: 0,
|
||||
Max: 100,
|
||||
Step: 5,
|
||||
},
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
151
internal/tui/update.go
Normal file
151
internal/tui/update.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// tickMsg is sent periodically to refresh the TUI
|
||||
type tickMsg time.Time
|
||||
|
||||
// tickCmd returns a command that sends a tickMsg after 100ms
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return tickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
// Init initializes the model and starts the tick loop
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tickCmd()
|
||||
}
|
||||
|
||||
// Update handles messages and updates the model
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tickMsg:
|
||||
// Tick fired, refresh and continue ticking
|
||||
return m, tickCmd()
|
||||
|
||||
case tea.KeyMsg:
|
||||
return m.handleKeypress(msg)
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// handleKeypress handles keyboard input
|
||||
func (m Model) handleKeypress(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
|
||||
case "tab":
|
||||
// Switch focus between panels
|
||||
if m.focusedPanel == PanelControls {
|
||||
m.focusedPanel = PanelLogs
|
||||
} else {
|
||||
m.focusedPanel = PanelControls
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "up", "k":
|
||||
if m.focusedPanel == PanelControls {
|
||||
if m.focusedCtrl > 0 {
|
||||
m.focusedCtrl--
|
||||
}
|
||||
} else {
|
||||
// Scroll logs up
|
||||
maxOffset := max(m.logBuffer.Len()-(m.height-10), 0)
|
||||
if m.logOffset < maxOffset {
|
||||
m.logOffset++
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "down", "j":
|
||||
if m.focusedPanel == PanelControls {
|
||||
if m.focusedCtrl < len(m.controls)-1 {
|
||||
m.focusedCtrl++
|
||||
}
|
||||
} else {
|
||||
// Scroll logs down
|
||||
if m.logOffset > 0 {
|
||||
m.logOffset--
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "left", "h":
|
||||
if m.focusedPanel == PanelControls {
|
||||
ctrl := &m.controls[m.focusedCtrl]
|
||||
if ctrl.Type == ControlSlider {
|
||||
value := ctrl.GetValue()
|
||||
newValue := max(value-ctrl.Step, ctrl.Min)
|
||||
ctrl.SetValue(newValue)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "right", "l":
|
||||
if m.focusedPanel == PanelControls {
|
||||
ctrl := &m.controls[m.focusedCtrl]
|
||||
if ctrl.Type == ControlSlider {
|
||||
value := ctrl.GetValue()
|
||||
newValue := min(value+ctrl.Step, ctrl.Max)
|
||||
ctrl.SetValue(newValue)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "enter", " ":
|
||||
if m.focusedPanel == PanelControls {
|
||||
ctrl := &m.controls[m.focusedCtrl]
|
||||
if ctrl.Type == ControlButton && ctrl.Action != nil {
|
||||
ctrl.Action()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "home":
|
||||
// Jump to top of logs
|
||||
if m.focusedPanel == PanelLogs {
|
||||
maxOffset := m.logBuffer.Len() - (m.height - 10)
|
||||
if maxOffset > 0 {
|
||||
m.logOffset = maxOffset
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "end":
|
||||
// Jump to bottom of logs
|
||||
if m.focusedPanel == PanelLogs {
|
||||
m.logOffset = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "pgup":
|
||||
if m.focusedPanel == PanelLogs {
|
||||
pageSize := m.height - 10
|
||||
maxOffset := max(m.logBuffer.Len()-pageSize, 0)
|
||||
m.logOffset = min(m.logOffset+pageSize, maxOffset)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "pgdown":
|
||||
if m.focusedPanel == PanelLogs {
|
||||
pageSize := m.height - 10
|
||||
m.logOffset = max(m.logOffset-pageSize, 0)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
290
internal/tui/view.go
Normal file
290
internal/tui/view.go
Normal file
@@ -0,0 +1,290 @@
|
||||
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: {10, 1000},
|
||||
11: {0, 1000},
|
||||
12: {0, 1000},
|
||||
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))
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user