internal/tui/app.go

// 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)
}