summaryrefslogtreecommitdiff
path: root/internal/tui/tui.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-23 23:52:55 +0200
committerPaul Buetow <paul@buetow.org>2026-02-23 23:52:55 +0200
commitb79a868fbc85cd7fb2829e978174629ab8a9c986 (patch)
tree6d80b9b1a45a43b8d251a518f3538817c34e3165 /internal/tui/tui.go
parent570b7b5d9283b9e443e7da25661e9f2098cc2305 (diff)
tui: add top-level model and run entrypoint
Diffstat (limited to 'internal/tui/tui.go')
-rw-r--r--internal/tui/tui.go233
1 files changed, 233 insertions, 0 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
new file mode 100644
index 0000000..758213d
--- /dev/null
+++ b/internal/tui/tui.go
@@ -0,0 +1,233 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "ior/internal/flags"
+ "ior/internal/tui/pidpicker"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// Screen identifies the currently active TUI screen.
+type Screen int
+
+const (
+ // ScreenPIDPicker is the PID selection screen.
+ ScreenPIDPicker Screen = iota
+ // ScreenDashboard is the runtime dashboard screen.
+ ScreenDashboard
+)
+
+// TraceStarter starts tracing and returns when startup succeeds or fails.
+// Long-lived tracing work should continue in background goroutines.
+type TraceStarter func(context.Context) error
+
+// Run starts the TUI program in alternate screen mode.
+func Run() error {
+ model := NewModel(flags.Get().PidFilter, defaultTraceStarter)
+ program := tea.NewProgram(model, tea.WithAltScreen())
+ _, err := program.Run()
+ return err
+}
+
+// Model is the top-level Bubble Tea model that routes between PID picker and dashboard.
+type Model struct {
+ screen Screen
+ pidPicker pidpicker.Model
+ dashboard dashboardModel
+
+ keys KeyMap
+
+ width int
+ height int
+ quitting bool
+
+ attaching bool
+ spin spinner.Model
+ lastErr error
+
+ startTrace TraceStarter
+ traceStop context.CancelFunc
+}
+
+// NewModel creates the top-level TUI model.
+func NewModel(initialPID int, startTrace TraceStarter) Model {
+ spin := spinner.New()
+ spin.Spinner = spinner.MiniDot
+ if startTrace == nil {
+ startTrace = defaultTraceStarter
+ }
+
+ model := Model{
+ screen: ScreenPIDPicker,
+ pidPicker: pidpicker.New(),
+ dashboard: newDashboardModel(),
+ keys: Keys,
+ spin: spin,
+ startTrace: startTrace,
+ }
+
+ if initialPID > 0 {
+ flags.SetPidFilter(initialPID)
+ model.dashboard.selectedPID = initialPID
+ model.screen = ScreenDashboard
+ model.attaching = true
+ }
+
+ return model
+}
+
+// Init initializes the active child model and optional tracing startup command.
+func (m Model) Init() tea.Cmd {
+ if m.screen == ScreenDashboard && m.attaching {
+ return tea.Batch(m.spin.Tick, m.beginTraceCmd())
+ }
+ return m.pidPicker.Init()
+}
+
+// Update routes messages, transitions screens, and manages tracing startup state.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ return m.updateActiveModel(msg)
+ case tea.KeyMsg:
+ if key.Matches(msg, m.keys.Quit) {
+ m.quitting = true
+ m.stopTrace()
+ return m, tea.Quit
+ }
+ case PidSelectedMsg:
+ return m.handlePidSelected(msg)
+ case TracingStartedMsg:
+ m.attaching = false
+ return m, nil
+ case TracingErrorMsg:
+ m.attaching = false
+ m.lastErr = msg.Err
+ return m, nil
+ }
+
+ if m.attaching {
+ var cmd tea.Cmd
+ m.spin, cmd = m.spin.Update(msg)
+ return m, cmd
+ }
+
+ return m.updateActiveModel(msg)
+}
+
+func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch m.screen {
+ case ScreenPIDPicker:
+ next, cmd := m.pidPicker.Update(msg)
+ m.pidPicker = next.(pidpicker.Model)
+ return m, cmd
+ case ScreenDashboard:
+ next, cmd := m.dashboard.Update(msg)
+ m.dashboard = next.(dashboardModel)
+ return m, cmd
+ default:
+ return m, nil
+ }
+}
+
+func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) {
+ pid := selectedPIDFilter(msg.Pid)
+ m.stopTrace()
+ flags.SetPidFilter(pid)
+ m.dashboard.selectedPID = pid
+ m.screen = ScreenDashboard
+ m.attaching = true
+ m.lastErr = nil
+ return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
+}
+
+func selectedPIDFilter(pid int) int {
+ if pid <= 0 {
+ return -1
+ }
+ return pid
+}
+
+func (m *Model) beginTraceCmd() tea.Cmd {
+ ctx, cancel := context.WithCancel(context.Background())
+ m.traceStop = cancel
+ return startTraceCmd(m.startTrace, ctx)
+}
+
+func startTraceCmd(starter TraceStarter, ctx context.Context) tea.Cmd {
+ return func() tea.Msg {
+ if err := starter(ctx); err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil
+ }
+ return TracingErrorMsg{Err: err}
+ }
+ return TracingStartedMsg{}
+ }
+}
+
+func defaultTraceStarter(context.Context) error {
+ return nil
+}
+
+func (m *Model) stopTrace() {
+ if m.traceStop != nil {
+ m.traceStop()
+ m.traceStop = nil
+ }
+}
+
+// View renders the currently active screen and startup overlay state.
+func (m Model) View() string {
+ if m.quitting {
+ return ""
+ }
+
+ if m.attaching {
+ line := fmt.Sprintf("%s Attaching tracepoints...", m.spin.View())
+ return ScreenStyle.Render(PanelStyle.Render(line))
+ }
+
+ if m.lastErr != nil {
+ return ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))
+ }
+
+ switch m.screen {
+ case ScreenPIDPicker:
+ return m.pidPicker.View()
+ case ScreenDashboard:
+ return m.dashboard.View()
+ default:
+ return ""
+ }
+}
+
+type dashboardModel struct {
+ selectedPID int
+}
+
+func newDashboardModel() dashboardModel {
+ return dashboardModel{selectedPID: -1}
+}
+
+func (d dashboardModel) Init() tea.Cmd {
+ return nil
+}
+
+func (d dashboardModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+ return d, nil
+}
+
+func (d dashboardModel) View() string {
+ if d.selectedPID > 0 {
+ return PanelStyle.Render(fmt.Sprintf("Dashboard (PID %d)", d.selectedPID))
+ }
+ return PanelStyle.Render("Dashboard (All PIDs)")
+}