Skip to content

Commit 35941eb

Browse files
Merge pull request #14 from clarabennett2626/feature/tui-core-scrolling
feat: Core TUI model + log list with virtual scrolling (closes #12)
2 parents 51e5bd6 + eabfffb commit 35941eb

3 files changed

Lines changed: 588 additions & 8 deletions

File tree

cmd/logpilot/main.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"os/signal"
8+
"strings"
89
"syscall"
910

1011
tea "github.com/charmbracelet/bubbletea"
@@ -34,13 +35,51 @@ func main() {
3435
return
3536
}
3637

37-
p := tea.NewProgram(tui.NewModel(), tea.WithAltScreen())
38-
if _, err := p.Run(); err != nil {
38+
// TUI mode — files given as args.
39+
files := os.Args[1:]
40+
if err := runTUIMode(files); err != nil {
3941
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
4042
os.Exit(1)
4143
}
4244
}
4345

46+
// runTUIMode starts the interactive TUI with file sources.
47+
func runTUIMode(files []string) error {
48+
ctx, cancel := context.WithCancel(context.Background())
49+
defer cancel()
50+
51+
sourceName := "no source"
52+
var src source.Source
53+
54+
if len(files) > 0 {
55+
sourceName = strings.Join(files, ", ")
56+
fileSrc := source.NewFileSource(source.FileConfig{
57+
Patterns: files,
58+
TailLines: 1000,
59+
})
60+
if err := fileSrc.Start(ctx); err != nil {
61+
return fmt.Errorf("starting file source: %w", err)
62+
}
63+
defer fileSrc.Stop()
64+
src = fileSrc
65+
}
66+
67+
model := tui.NewModelWithSource(src, sourceName)
68+
p := tea.NewProgram(model, tea.WithAltScreen())
69+
70+
// Wire source lines into the TUI via Program.Send.
71+
if src != nil {
72+
autoParser := parser.NewAutoParser()
73+
renderer := tui.NewRenderer(tui.DefaultConfig())
74+
tui.ListenForLines(src, autoParser, renderer, p)
75+
}
76+
77+
if _, err := p.Run(); err != nil {
78+
return err
79+
}
80+
return nil
81+
}
82+
4483
// runPipeMode reads from stdin, parses each line, and renders output to stdout.
4584
func runPipeMode() error {
4685
ctx, cancel := context.WithCancel(context.Background())

internal/tui/model.go

Lines changed: 248 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package tui
22

33
import (
44
"fmt"
5+
"strings"
56

67
tea "github.com/charmbracelet/bubbletea"
78
"github.com/charmbracelet/lipgloss"
9+
"github.com/clarabennett2626/logpilot/internal/parser"
10+
"github.com/clarabennett2626/logpilot/internal/source"
811
)
912

1013
var (
@@ -14,20 +17,98 @@ var (
1417
Background(lipgloss.Color("#7D56F4")).
1518
Padding(0, 1)
1619

17-
statusStyle = lipgloss.NewStyle().
18-
Foreground(lipgloss.Color("#666666"))
20+
statusBarStyle = lipgloss.NewStyle().
21+
Foreground(lipgloss.Color("#FAFAFA")).
22+
Background(lipgloss.Color("#333333")).
23+
Padding(0, 1)
24+
25+
statusKeyStyle = lipgloss.NewStyle().
26+
Foreground(lipgloss.Color("#7D56F4")).
27+
Background(lipgloss.Color("#333333")).
28+
Bold(true).
29+
Padding(0, 1)
1930
)
2031

32+
// LogMsg carries a new parsed and rendered log line into the TUI.
33+
type LogMsg struct {
34+
Rendered string
35+
}
36+
37+
// LogBatchMsg carries multiple rendered log lines at once.
38+
type LogBatchMsg struct {
39+
Lines []string
40+
}
41+
42+
// ErrMsg carries a source error into the TUI.
43+
type ErrMsg struct {
44+
Err error
45+
}
46+
2147
// Model is the main TUI model for LogPilot.
2248
type Model struct {
2349
width int
2450
height int
2551
ready bool
52+
53+
// Log buffer — stores rendered strings for display.
54+
lines []string
55+
56+
// Virtual scrolling state.
57+
offset int // index of the first visible line
58+
autoScroll bool // stick to bottom when new lines arrive
59+
60+
// Source info for status bar.
61+
sourceName string
2662
}
2763

28-
// NewModel creates a new LogPilot TUI model.
64+
// NewModel creates a new LogPilot TUI model with no sources.
2965
func NewModel() Model {
30-
return Model{}
66+
return Model{
67+
autoScroll: true,
68+
}
69+
}
70+
71+
// NewModelWithSource creates a TUI model wired to a log source.
72+
func NewModelWithSource(src source.Source, sourceName string) Model {
73+
return Model{
74+
autoScroll: true,
75+
sourceName: sourceName,
76+
}
77+
}
78+
79+
// viewHeight returns the number of lines available for log display
80+
// (total height minus title bar and status bar).
81+
func (m Model) viewHeight() int {
82+
// 1 line title + 1 blank + 1 status bar = 3 overhead lines
83+
h := m.height - 3
84+
if h < 1 {
85+
return 1
86+
}
87+
return h
88+
}
89+
90+
// maxOffset returns the maximum valid scroll offset.
91+
func (m Model) maxOffset() int {
92+
max := len(m.lines) - m.viewHeight()
93+
if max < 0 {
94+
return 0
95+
}
96+
return max
97+
}
98+
99+
// clampOffset ensures offset is within valid bounds.
100+
func (m *Model) clampOffset() {
101+
if m.offset < 0 {
102+
m.offset = 0
103+
}
104+
if max := m.maxOffset(); m.offset > max {
105+
m.offset = max
106+
}
107+
}
108+
109+
// isAtBottom returns true if the viewport is scrolled to the bottom.
110+
func (m Model) isAtBottom() bool {
111+
return m.offset >= m.maxOffset()
31112
}
32113

33114
// Init initializes the model.
@@ -42,11 +123,74 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42123
switch msg.String() {
43124
case "q", "ctrl+c":
44125
return m, tea.Quit
126+
case "j", "down":
127+
m.autoScroll = false
128+
m.offset++
129+
m.clampOffset()
130+
if m.isAtBottom() {
131+
m.autoScroll = true
132+
}
133+
case "k", "up":
134+
m.autoScroll = false
135+
m.offset--
136+
m.clampOffset()
137+
case "g", "home":
138+
m.autoScroll = false
139+
m.offset = 0
140+
case "G", "end":
141+
m.offset = m.maxOffset()
142+
m.autoScroll = true
143+
case "pgdown", "f", "ctrl+f":
144+
m.autoScroll = false
145+
m.offset += m.viewHeight()
146+
m.clampOffset()
147+
if m.isAtBottom() {
148+
m.autoScroll = true
149+
}
150+
case "pgup", "b", "ctrl+b":
151+
m.autoScroll = false
152+
m.offset -= m.viewHeight()
153+
m.clampOffset()
154+
case "d", "ctrl+d":
155+
m.autoScroll = false
156+
m.offset += m.viewHeight() / 2
157+
m.clampOffset()
158+
if m.isAtBottom() {
159+
m.autoScroll = true
160+
}
161+
case "u", "ctrl+u":
162+
m.autoScroll = false
163+
m.offset -= m.viewHeight() / 2
164+
m.clampOffset()
45165
}
166+
46167
case tea.WindowSizeMsg:
47168
m.width = msg.Width
48169
m.height = msg.Height
49170
m.ready = true
171+
if m.autoScroll {
172+
m.offset = m.maxOffset()
173+
}
174+
m.clampOffset()
175+
176+
case LogMsg:
177+
m.lines = append(m.lines, msg.Rendered)
178+
if m.autoScroll {
179+
m.offset = m.maxOffset()
180+
}
181+
182+
case LogBatchMsg:
183+
m.lines = append(m.lines, msg.Lines...)
184+
if m.autoScroll {
185+
m.offset = m.maxOffset()
186+
}
187+
188+
case ErrMsg:
189+
// Show error as a log line.
190+
m.lines = append(m.lines, fmt.Sprintf("ERROR: %v", msg.Err))
191+
if m.autoScroll {
192+
m.offset = m.maxOffset()
193+
}
50194
}
51195
return m, nil
52196
}
@@ -57,8 +201,106 @@ func (m Model) View() string {
57201
return "Loading..."
58202
}
59203

204+
var b strings.Builder
205+
206+
// Title bar.
60207
title := titleStyle.Render("LogPilot")
61-
status := statusStyle.Render(fmt.Sprintf("Terminal: %dx%d | Press 'q' to quit", m.width, m.height))
208+
b.WriteString(title)
209+
b.WriteByte('\n')
62210

63-
return fmt.Sprintf("%s\n\n No log sources connected.\n Usage: logpilot <file.log>\n\n%s", title, status)
211+
// Log viewport — virtual scrolling: only render visible slice.
212+
vh := m.viewHeight()
213+
if len(m.lines) == 0 {
214+
// Empty state.
215+
for i := 0; i < vh; i++ {
216+
if i == vh/2-1 {
217+
b.WriteString(" No log entries yet.")
218+
} else if i == vh/2 {
219+
b.WriteString(" Waiting for input...")
220+
}
221+
b.WriteByte('\n')
222+
}
223+
} else {
224+
end := m.offset + vh
225+
if end > len(m.lines) {
226+
end = len(m.lines)
227+
}
228+
start := m.offset
229+
if start < 0 {
230+
start = 0
231+
}
232+
// Render visible lines.
233+
rendered := 0
234+
for i := start; i < end; i++ {
235+
b.WriteString(m.lines[i])
236+
b.WriteByte('\n')
237+
rendered++
238+
}
239+
// Pad remaining lines.
240+
for i := rendered; i < vh; i++ {
241+
b.WriteByte('\n')
242+
}
243+
}
244+
245+
// Status bar.
246+
total := len(m.lines)
247+
scrollInfo := "bottom"
248+
if total > 0 && !m.isAtBottom() {
249+
pct := 0
250+
if m.maxOffset() > 0 {
251+
pct = m.offset * 100 / m.maxOffset()
252+
}
253+
scrollInfo = fmt.Sprintf("%d%%", pct)
254+
}
255+
256+
src := m.sourceName
257+
if src == "" {
258+
src = "stdin"
259+
}
260+
261+
left := statusKeyStyle.Render("Lines:") + statusBarStyle.Render(fmt.Sprintf(" %d ", total))
262+
right := statusKeyStyle.Render("Pos:") + statusBarStyle.Render(fmt.Sprintf(" %s ", scrollInfo))
263+
srcInfo := statusKeyStyle.Render("Src:") + statusBarStyle.Render(fmt.Sprintf(" %s ", src))
264+
265+
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right) - lipgloss.Width(srcInfo)
266+
if gap < 0 {
267+
gap = 0
268+
}
269+
statusLine := left + srcInfo + strings.Repeat(" ", gap) + right
270+
// Fill background.
271+
statusLine = statusBarStyle.Render(statusLine)
272+
b.WriteString(statusLine)
273+
274+
return b.String()
275+
}
276+
277+
// WaitForLines returns a tea.Cmd that reads from a source and sends LogMsg
278+
// messages to the TUI. Call this to wire a source into the model.
279+
func WaitForLines(src source.Source, p *parser.AutoParser, r *Renderer) tea.Cmd {
280+
return func() tea.Msg {
281+
line, ok := <-src.Lines()
282+
if !ok {
283+
return nil
284+
}
285+
entry := p.Parse(line.Line)
286+
rendered := r.RenderEntry(entry)
287+
return LogMsg{Rendered: rendered}
288+
}
289+
}
290+
291+
// ListenForLines returns a tea.Cmd that continuously reads from a source
292+
// and sends lines to the program. Use with tea.Program.Send from a goroutine.
293+
func ListenForLines(src source.Source, p *parser.AutoParser, r *Renderer, prog *tea.Program) {
294+
go func() {
295+
for line := range src.Lines() {
296+
entry := p.Parse(line.Line)
297+
rendered := r.RenderEntry(entry)
298+
prog.Send(LogMsg{Rendered: rendered})
299+
}
300+
}()
301+
go func() {
302+
for err := range src.Errors() {
303+
prog.Send(ErrMsg{Err: err})
304+
}
305+
}()
64306
}

0 commit comments

Comments
 (0)