// Package tui composes the top-level bubbletea model. Sub-views live
// under view/*; this file is mostly message routing and window-size
// propagation.
//
// mercemay.top/src/httptap/
package tui
import (
tea "github.com/charmbracelet/bubbletea"
"mercemay.top/httptap/internal/parser"
"mercemay.top/httptap/internal/tui/keys"
"mercemay.top/httptap/internal/tui/theme"
"mercemay.top/httptap/internal/tui/view/detail"
"mercemay.top/httptap/internal/tui/view/filterbar"
"mercemay.top/httptap/internal/tui/view/list"
"mercemay.top/httptap/internal/tui/view/statusbar"
)
// MessageReceivedMsg is emitted from outside the TUI when the parser
// hands us a fresh Message.
type MessageReceivedMsg struct{ M parser.Message }
// focus tracks which sub-view the keyboard directs its events to.
type focus uint8
const (
focusList focus = iota
focusDetail
focusFilter
)
// App is the root bubbletea.Model for httptap.
type App struct {
width, height int
list list.Model
detail detail.Model
filter filterbar.Model
status statusbar.Model
theme theme.Palette
keys keys.Map
focus focus
}
// New returns an App with default-sized children; a WindowSizeMsg will
// immediately re-layout them.
func New() App {
pal := theme.Default()
return App{
list: list.New(pal),
detail: detail.New(pal),
filter: filterbar.New(pal),
status: statusbar.New(pal),
theme: pal,
keys: keys.Default(),
focus: focusList,
}
}
// Init satisfies tea.Model; there is no startup command yet.
func (a App) Init() tea.Cmd { return nil }
// Update dispatches incoming messages to children based on focus.
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch m := msg.(type) {
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
a.relayout()
return a, nil
case tea.KeyMsg:
return a.handleKey(m)
case MessageReceivedMsg:
a.list.Append(m.M)
a.status.SetCount(a.list.Len())
return a, nil
}
return a, nil
}
func (a App) handleKey(m tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case a.keys.Matches(m, a.keys.Quit):
return a, tea.Quit
case a.keys.Matches(m, a.keys.ToggleFilter):
if a.focus == focusFilter {
a.focus = focusList
} else {
a.focus = focusFilter
}
return a, nil
case a.keys.Matches(m, a.keys.FocusDetail):
a.focus = focusDetail
return a, nil
case a.keys.Matches(m, a.keys.FocusList):
a.focus = focusList
return a, nil
}
var cmd tea.Cmd
switch a.focus {
case focusList:
a.list, cmd = a.list.Update(m)
if sel, ok := a.list.Selected(); ok {
a.detail.SetMessage(sel)
}
case focusDetail:
a.detail, cmd = a.detail.Update(m)
case focusFilter:
a.filter, cmd = a.filter.Update(m)
a.list.SetFilter(a.filter.Expr())
}
return a, cmd
}
// View paints the assembled layout: list + detail stacked vertically
// on narrow terminals, side-by-side otherwise. A filter bar sits above
// the list and the status bar clamps to the bottom row.
func (a App) View() string {
return a.theme.Screen.Render(
a.filter.View() + "\n" +
a.list.View() + "\n" +
a.detail.View() + "\n" +
a.status.View(),
)
}
func (a *App) relayout() {
// Reserve 1 row for filter bar, 1 row for status bar.
contentHeight := a.height - 2
listH := contentHeight / 3
detailH := contentHeight - listH
a.list.SetSize(a.width, listH)
a.detail.SetSize(a.width, detailH)
a.filter.SetWidth(a.width)
a.status.SetWidth(a.width)
}