diff --git a/internal/tui/model.go b/internal/tui/model.go index 59fd773..60e1e54 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -14,12 +14,26 @@ const ( maxRequests = 256 ) +const ( + focusPaneRequests = iota + focusPaneDetails + focusPaneEvents + focusPaneStd +) + type Model struct { channel <-chan model.Event controls Controls - events []model.Event - requests []model.Request + events []model.Event + requests []model.Request + requestCursor int + requestScroll int + detailsTab int + detailsScroll int + eventsScroll int + stdScroll int + focusedPane int width int height int @@ -39,17 +53,24 @@ type Controls struct { func NewModel(ch <-chan model.Event, controls Controls) Model { return Model{ - channel: ch, - controls: controls, - events: make([]model.Event, 0, maxEvents), - requests: make([]model.Request, 0, maxRequests), - width: 0, - height: 0, - showEvents: false, - showStd: false, - showSearch: false, - restarting: false, - theme: newTheme(), + channel: ch, + controls: controls, + events: make([]model.Event, 0, maxEvents), + requests: make([]model.Request, 0, maxRequests), + requestCursor: 0, + requestScroll: 0, + detailsTab: detailsTabOverview, + detailsScroll: 0, + eventsScroll: 0, + stdScroll: 0, + focusedPane: focusPaneRequests, + width: 0, + height: 0, + showEvents: false, + showStd: false, + showSearch: false, + restarting: false, + theme: newTheme(), } } diff --git a/internal/tui/panes.go b/internal/tui/panes.go index 3fa3c06..f88e0c5 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -1,16 +1,31 @@ package tui import ( + "bytes" + "encoding/hex" + "encoding/json" "fmt" + "sort" "strings" "time" + "unicode/utf8" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "termtap.dev/internal/model" ) // TODO: LOTS OF THIS SUCKS BUT IT WORKS +const ( + detailsTabOverview = iota + detailsTabRequest + detailsTabResponse + detailsTabHeaders +) + +var detailsTabNames = []string{"Overview", "Request", "Response", "Headers"} + func (m Model) renderStatusBar(w int) string { var errCount int var msSum int64 @@ -23,7 +38,15 @@ func (m Model) renderStatusBar(w int) string { avg := int(msSum) / max(1, len(m.requests)) left := fmt.Sprintf(" tap %3d reqs | %d err | avg %dms", len(m.requests), errCount, avg) - right := "j/k nav / search tab panel e events o output ^r restart q quit " + logState := "logs off" + if m.showEvents && m.showStd { + logState = "events+output" + } else if m.showEvents { + logState = "events" + } else if m.showStd { + logState = "output" + } + right := " " + logState + " " spaceSize := max(w-(len(left)+len(right)), 0) space := strings.Repeat(" ", spaceSize) @@ -31,6 +54,91 @@ func (m Model) renderStatusBar(w int) string { return m.theme.Header.Render(left + space + right) } +func (m Model) renderBottomStatusBar(w int) string { + if w <= 0 { + return "" + } + + modeLabel := "REQ" + modeColor := blue + switch m.focusedPane { + case focusPaneDetails: + modeLabel = "DETAIL" + modeColor = blue + case focusPaneEvents: + modeLabel = "EVENT" + modeColor = green + case focusPaneStd: + modeLabel = "OUT" + modeColor = orange + } + + modeStyle := lipgloss.NewStyle().Foreground(background).Background(modeColor).Bold(true) + left := modeStyle.Render(" " + modeLabel + " ") + if m.restarting { + left += m.theme.Text.Render(" ") + m.theme.EventWarn.Render(" RESTARTING ") + } + + right := m.theme.TextMuted.Render(" " + m.bottomStatusRight() + " ") + if lipgloss.Width(right) >= w { + return clampRendered(right, w) + } + + maxLeft := max(0, w-lipgloss.Width(right)-1) + left = clampRendered(left, maxLeft) + spaceSize := max(1, w-lipgloss.Width(left)-lipgloss.Width(right)) + space := m.theme.Text.Render(strings.Repeat(" ", spaceSize)) + + return clampRendered(left+space+right, w) +} + +func (m Model) bottomStatusRight() string { + selected, total := m.requestSelectionStats() + + switch m.focusedPane { + case focusPaneRequests: + return fmt.Sprintf("req %d/%d", selected, total) + + case focusPaneDetails: + tab := "overview" + if m.detailsTab >= 0 && m.detailsTab < len(detailsTabNames) { + tab = strings.ToLower(detailsTabNames[m.detailsTab]) + } + linesTotal := m.detailsContentLineCount(m.detailsPaneWidth()) + linesPos := 0 + if linesTotal > 0 { + linesPos = min(linesTotal, m.detailsScroll+1) + } + return fmt.Sprintf("req %d/%d | tab %s | %d/%d", selected, total, tab, linesPos, linesTotal) + + case focusPaneEvents: + count := m.eventCount() + if m.eventsScroll == 0 { + return fmt.Sprintf("events %d | LIVE", count) + } + return fmt.Sprintf("events %d | PAUSED", count) + + case focusPaneStd: + count := m.stdLogCount() + if m.stdScroll == 0 { + return fmt.Sprintf("lines %d | LIVE", count) + } + return fmt.Sprintf("lines %d | PAUSED", count) + } + + return "" +} + +func (m Model) requestSelectionStats() (selected int, total int) { + total = len(m.requests) + if total == 0 { + return 0, 0 + } + + selected = min(total, max(1, m.requestCursor+1)) + return selected, total +} + // TODO: Implement func (m Model) renderSearchPane(w, h int) []string { lines := make([]string, h) @@ -43,39 +151,79 @@ func (m Model) renderSearchPane(w, h int) []string { func (m Model) renderRequestPane(w, h int) []string { var lines []string + titleStyle := m.theme.TextMuted + if m.focusedPane == focusPaneRequests { + titleStyle = m.theme.EventHeader + } + title := titleStyle.Render(padToWidth("[1] REQUESTS", w)) + lines = append(lines, title) + // Render header headerLeft := fmt.Sprintf(" %-7s %-24s %s", "METHOD", "HOST", "PATH") headerRight := fmt.Sprintf("%4s %8s ", "CODE", "TIME") headerSpace := strings.Repeat(" ", max(0, w-len(headerLeft+headerRight))) - header := headerLeft + headerSpace + headerRight + header := m.theme.TextMuted.Render(headerLeft + headerSpace + headerRight) lines = append(lines, header) - for i := len(m.requests) - 1; i >= 0; i-- { + bodyLines := make([]string, 0, len(m.requests)) + for i, row := len(m.requests)-1, 0; i >= 0; i, row = i-1, row+1 { req := m.requests[i] - - // Some formatting magic here maybe - left := fmt.Sprintf( - " %-7s %-24s %s", - strings.ToUpper(req.Method), - truncate(req.Host, 24), - req.URL, - ) - right := fmt.Sprintf( - "%4d %8s ", - req.Status, - formatDuration(req.Duration), - ) + duration := req.Duration if req.Pending && !req.StartTime.IsZero() { - right = fmt.Sprintf( - "%4s %8s ", - "", - formatDuration(time.Since(req.StartTime)), - ) + duration = time.Since(req.StartTime) } - space := strings.Repeat(" ", max(0, w-len(left+right))) - line := left + space + right - lines = append(lines, line) + statusStyle := lipgloss.NewStyle().Foreground(green).Background(background).Bold(true) + if req.Failed || req.Status >= 500 { + statusStyle = lipgloss.NewStyle().Foreground(red).Background(background).Bold(true) + } else if req.Status >= 400 { + statusStyle = lipgloss.NewStyle().Foreground(orange).Background(background).Bold(true) + } + + latencyStyle := m.theme.Text + if duration >= 2*time.Second { + latencyStyle = lipgloss.NewStyle().Foreground(orange).Background(background).Bold(true) + } + + methodCol := statusStyle.Render(fmt.Sprintf("%-7s", truncate(strings.ToUpper(req.Method), 7))) + hostCol := m.theme.Text.Render(fmt.Sprintf("%-24s", truncate(req.Host, 24))) + pathCol := m.theme.Text.Render(req.URL) + + statusText := "" + if !req.Pending && req.Status > 0 { + statusText = fmt.Sprintf("%d", req.Status) + } + statusCol := statusStyle.Render(fmt.Sprintf("%4s", statusText)) + timeCol := latencyStyle.Render(fmt.Sprintf("%8s", formatDuration(duration))) + + sep := m.theme.Text.Render(" ") + left := sep + methodCol + sep + hostCol + sep + pathCol + right := statusCol + sep + timeCol + sep + space := strings.Repeat(" ", max(0, w-lipgloss.Width(left+right))) + + line := left + m.theme.Text.Render(space) + right + line = clampRendered(line, w) + if row == m.requestCursor { + line = m.theme.RequestSelected.Render(ansi.Strip(line)) + } + bodyLines = append(bodyLines, line) + } + + bodyHeight := max(0, h-len(lines)) + scroll := m.requestScroll + maxScroll := max(0, len(bodyLines)-bodyHeight) + if scroll < 0 { + scroll = 0 + } + if scroll > maxScroll { + scroll = maxScroll + } + + if bodyHeight > 0 { + end := min(len(bodyLines), scroll+bodyHeight) + if scroll < end { + lines = append(lines, bodyLines[scroll:end]...) + } } // Cleanup @@ -92,16 +240,396 @@ func (m Model) renderRequestPane(w, h int) []string { return lines } -// TODO: Implement func (m Model) renderDetailsPane(w, h int) []string { - lines := make([]string, h) - for y := range lines { - lines[y] = m.theme.Text.Render(strings.Repeat(" ", w)) + if h <= 0 { + return nil + } + + formatLine := func(content string) string { + line := truncate(content, w) + if len(line) < w { + line += strings.Repeat(" ", w-len(line)) + } + return m.theme.Text.Render(line) + } + + formatMutedLine := func(content string) string { + line := truncate(content, w) + if len(line) < w { + line += strings.Repeat(" ", w-len(line)) + } + return m.theme.TextMuted.Render(line) + } + + formatMutedItalicLine := func(content string) string { + line := truncate(content, w) + if len(line) < w { + line += strings.Repeat(" ", w-len(line)) + } + return lipgloss.NewStyle(). + Foreground(textMuted). + Background(background). + Italic(true). + Render(line) + } + + renderTabRow := func() string { + if w <= 0 { + return "" + } + + activeTabStyle := lipgloss.NewStyle(). + Foreground(background). + Background(blue). + Bold(true) + + var parts []string + for i, name := range detailsTabNames { + label := " " + name + " " + if i == m.detailsTab { + label = " [" + name + "] " + parts = append(parts, activeTabStyle.Render(label)) + continue + } + parts = append(parts, m.theme.TextMuted.Render(label)) + } + + sep := m.theme.Text.Render(" ") + line := strings.Join(parts, sep) + if lipgloss.Width(line) < w { + pad := strings.Repeat(" ", w-lipgloss.Width(line)) + line += m.theme.Text.Render(pad) + } + + return clampRendered(line, w) + } + + lines := make([]string, 0, h) + detailsTitleStyle := m.theme.TextMuted + if m.focusedPane == focusPaneDetails { + detailsTitleStyle = m.theme.EventHeader + } + lines = append(lines, detailsTitleStyle.Render(padToWidth("[2] DETAIL", w))) + if len(lines) >= h { + return lines[:h] + } + lines = append(lines, renderTabRow()) + if len(lines) >= h { + return lines[:h] + } + + contentLines := m.detailsContentLines(w, formatLine, formatMutedLine, formatMutedItalicLine) + contentHeight := max(0, h-len(lines)) + scroll := m.detailsScroll + maxScroll := max(0, len(contentLines)-contentHeight) + if scroll < 0 { + scroll = 0 + } + if scroll > maxScroll { + scroll = maxScroll + } + + if contentHeight > 0 { + end := min(len(contentLines), scroll+contentHeight) + if scroll < end { + lines = append(lines, contentLines[scroll:end]...) + } + } + + for len(lines) < h { + lines = append(lines, formatLine("")) + } + + if len(lines) > h { + return lines[:h] } return lines } +func padToWidth(s string, w int) string { + if w <= 0 { + return "" + } + + s = truncate(s, w) + if len(s) < w { + s += strings.Repeat(" ", w-len(s)) + } + + return s +} + +func (m Model) detailsContentLines( + w int, + formatLine func(string) string, + formatMutedLine func(string) string, + formatMutedItalicLine func(string) string, +) []string { + selectedReq, ok := m.selectedRequest() + if !ok { + return []string{formatLine(" No requests yet. Use j/k once requests arrive.")} + } + + contentLines := make([]string, 0, 64) + + switch m.detailsTab { + case detailsTabOverview: + duration := selectedReq.Duration + if selectedReq.Pending && !selectedReq.StartTime.IsZero() { + duration = time.Since(selectedReq.StartTime) + } + + renderKVLine := func(key, value string, valueStyle lipgloss.Style) string { + keyPart := m.theme.TextMuted.Render(fmt.Sprintf(" %-8s", key)) + valuePart := valueStyle.Render(value) + sep := m.theme.Text.Render(" ") + line := keyPart + sep + valuePart + if lipgloss.Width(line) < w { + line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line))) + } + return clampRendered(line, w) + } + + statusText := "-" + statusStyle := m.theme.TextMuted + if selectedReq.Status > 0 { + statusText = fmt.Sprintf("%d", selectedReq.Status) + statusStyle = m.theme.EventSuccess + if selectedReq.Status >= 400 { + statusStyle = m.theme.EventWarn + } + if selectedReq.Status >= 500 { + statusStyle = m.theme.EventError + } + } + + timeText := "-" + if !selectedReq.StartTime.IsZero() { + timeText = selectedReq.StartTime.Format("3:04:05 PM") + } + + contentLines = append(contentLines, formatLine("")) + contentLines = append(contentLines, renderKVLine("Method", strings.ToUpper(selectedReq.Method), m.theme.Text)) + contentLines = append(contentLines, renderKVLine("URL", selectedReq.RawURL, m.theme.TextMuted)) + queryText := selectedReq.QueryString + if queryText == "" { + queryText = "-" + } + contentLines = append(contentLines, renderKVLine("Query", queryText, m.theme.TextMuted)) + contentLines = append(contentLines, renderKVLine("Status", statusText, statusStyle)) + contentLines = append(contentLines, renderKVLine("Latency", formatDuration(duration), m.theme.Text)) + contentLines = append(contentLines, renderKVLine("Time", timeText, m.theme.Text)) + + contentLines = append(contentLines, formatLine("")) + contentLines = append(contentLines, formatMutedLine(" Timing")) + + barValue := formatDuration(duration) + barPrefix := " " + barSuffix := " " + barValue + maxBarWidth := max(1, w/2) + barWidth := min(maxBarWidth, max(0, w-len(barPrefix)-len(barSuffix))) + if barWidth == 0 { + contentLines = append(contentLines, formatLine(" "+barValue)) + break + } + + const maxDurationForScale = 2 * time.Second + ratio := float64(duration) / float64(maxDurationForScale) + if ratio < 0 { + ratio = 0 + } + if ratio > 1 { + ratio = 1 + } + + filled := int(ratio * float64(barWidth)) + if duration > 0 && filled == 0 { + filled = 1 + } + if filled > barWidth { + filled = barWidth + } + empty := max(0, barWidth-filled) + + filledPart := lipgloss.NewStyle().Foreground(blue).Render(strings.Repeat("█", filled)) + emptyPart := lipgloss.NewStyle().Foreground(cyan).Render(strings.Repeat("░", empty)) + barLine := barPrefix + filledPart + emptyPart + m.theme.TextMuted.Render(barSuffix) + contentLines = append(contentLines, clampRendered(barLine, w)) + + case detailsTabRequest: + contentLines = append(contentLines, formatLine("")) + contentLines = append(contentLines, formatMutedLine(" -- Request Body --")) + contentLines = append(contentLines, formatLine("")) + if len(selectedReq.RequestData) == 0 { + contentLines = append(contentLines, formatMutedItalicLine(" empty")) + break + } + for _, line := range formatBodyLines(prettyBody(selectedReq.RequestData, selectedReq.RequestHeaders), w) { + contentLines = append(contentLines, formatLine(" "+line)) + } + + case detailsTabResponse: + contentLines = append(contentLines, formatLine("")) + contentLines = append(contentLines, formatMutedLine(" -- Response Body --")) + contentLines = append(contentLines, formatLine("")) + if len(selectedReq.ResponseData) == 0 { + contentLines = append(contentLines, formatMutedItalicLine(" empty")) + break + } + for _, line := range formatBodyLines(prettyBody(selectedReq.ResponseData, selectedReq.ResponseHeaders), w) { + contentLines = append(contentLines, formatLine(" "+line)) + } + + case detailsTabHeaders: + contentLines = append(contentLines, formatLine("")) + renderHeaderLine := func(key, value string) string { + left := m.theme.HeaderKey.Render(" " + key + ": ") + right := m.theme.TextMuted.Render(value) + line := left + right + if lipgloss.Width(line) < w { + line += m.theme.Text.Render(strings.Repeat(" ", w-lipgloss.Width(line))) + } + return clampRendered(line, w) + } + + appendHeaders := func(title string, headers map[string][]string) { + contentLines = append(contentLines, formatMutedLine(" -- "+title+" --")) + contentLines = append(contentLines, formatLine("")) + + if len(headers) == 0 { + contentLines = append(contentLines, formatMutedItalicLine(" empty")) + contentLines = append(contentLines, formatLine("")) + return + } + + keys := make([]string, 0, len(headers)) + for key := range headers { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + contentLines = append(contentLines, renderHeaderLine(key, strings.Join(headers[key], ", "))) + } + + contentLines = append(contentLines, formatLine("")) + } + + appendHeaders("Request Headers", selectedReq.RequestHeaders) + appendHeaders("Response Headers", selectedReq.ResponseHeaders) + + default: + contentLines = append(contentLines, formatLine(" Unknown details tab")) + } + + return contentLines +} + +func (m Model) detailsContentLineCount(w int) int { + plain := func(s string) string { return s } + return len(m.detailsContentLines(w, plain, plain, plain)) +} + +func (m Model) selectedRequest() (model.Request, bool) { + if len(m.requests) == 0 { + return model.Request{}, false + } + + cursor := m.requestCursor + if cursor < 0 { + cursor = 0 + } + if cursor >= len(m.requests) { + cursor = len(m.requests) - 1 + } + + idx := len(m.requests) - cursor - 1 + if idx < 0 || idx >= len(m.requests) { + return model.Request{}, false + } + + return m.requests[idx], true +} + +func formatBodyLines(body []byte, width int) []string { + if len(body) == 0 { + return []string{"(empty)"} + } + + text := string(body) + if !utf8.Valid(body) { + previewSize := min(len(body), 64) + hexPreview := hex.EncodeToString(body[:previewSize]) + suffix := "" + if previewSize < len(body) { + suffix = "..." + } + text = fmt.Sprintf("(binary payload, %d bytes, hex preview: %s%s)", len(body), hexPreview, suffix) + } + + src := strings.ReplaceAll(text, "\t", " ") + rawLines := strings.Split(src, "\n") + if width <= 4 { + return rawLines + } + + maxWidth := max(1, width-2) + out := make([]string, 0, len(rawLines)) + for _, line := range rawLines { + if line == "" { + out = append(out, "") + continue + } + + for len(line) > maxWidth { + out = append(out, line[:maxWidth]) + line = line[maxWidth:] + } + out = append(out, line) + } + + return out +} + +func prettyBody(body []byte, headers map[string][]string) []byte { + if len(body) == 0 { + return body + } + + if !looksLikeJSON(body, headers) { + return body + } + + var out bytes.Buffer + if err := json.Indent(&out, body, "", " "); err != nil { + return body + } + + return out.Bytes() +} + +func looksLikeJSON(body []byte, headers map[string][]string) bool { + if json.Valid(body) { + return true + } + + for key, values := range headers { + if !strings.EqualFold(key, "Content-Type") { + continue + } + + for _, value := range values { + contentType := strings.ToLower(value) + if strings.Contains(contentType, "application/json") || strings.Contains(contentType, "+json") { + return true + } + } + } + + return false +} + // TODO: This can be done better // TODO: Should h be max or defined? func (m Model) renderEventsPane(w, h int) []string { @@ -114,18 +642,30 @@ func (m Model) renderEventsPane(w, h int) []string { } } - displayCount := max(h-1, 0) - - if displayCount < len(events) { - events = events[len(events)-displayCount:] - } - - left := fmt.Sprintf("EVENT LOG - %d EVENTS", len(events)) + left := fmt.Sprintf("[3] EVENT LOG - %d EVENTS", len(events)) right := "E: TOGGLE" - status := m.theme.EventHeader.Render(left + strings.Repeat(" ", w-len(left+right)) + right) + headerStyle := m.theme.TextMuted + if m.focusedPane == focusPaneEvents { + headerStyle = m.theme.EventPaneHeader + } + status := headerStyle.Render(left + strings.Repeat(" ", max(0, w-len(left+right))) + right) lines := []string{status} - for _, event := range events { + bodyHeight := max(0, h-1) + maxScroll := max(0, len(events)-bodyHeight) + scroll := m.eventsScroll + if scroll < 0 { + scroll = 0 + } + if scroll > maxScroll { + scroll = maxScroll + } + + start := max(0, len(events)-bodyHeight-scroll) + end := min(len(events), start+bodyHeight) + visible := events[start:end] + + for _, event := range visible { var ( eTime string = m.theme.TextMuted.Render(event.Time.Format("15:04:05") + " ") eType string = getEventColor(m.theme, event.Type).Render(fmt.Sprintf("%-17s ", event.Type)) @@ -165,6 +705,16 @@ func (m Model) renderEventsPane(w, h int) []string { return lines } +func (m Model) eventCount() int { + count := 0 + for _, ev := range m.events { + if ev.Type != model.EventTypeProcessStderr && ev.Type != model.EventTypeProcessStdout { + count++ + } + } + return count +} + // TODO: Should h be max or defined? func (m Model) renderStdPane(w, h int) []string { // Only the stdout or stderr logs @@ -176,18 +726,30 @@ func (m Model) renderStdPane(w, h int) []string { } } - displayCount := max(h-1, 0) - - if displayCount < len(logs) { - logs = logs[len(logs)-displayCount:] - } - - left := fmt.Sprintf("STDOUT/STDERR LOG - %d LINES", len(logs)) + left := fmt.Sprintf("[4] STDOUT/STDERR LOG - %d LINES", len(logs)) right := "O: TOGGLE" - status := m.theme.StdHeader.Render(left + strings.Repeat(" ", w-len(left+right)) + right) + headerStyle := m.theme.TextMuted + if m.focusedPane == focusPaneStd { + headerStyle = m.theme.StdHeader + } + status := headerStyle.Render(left + strings.Repeat(" ", max(0, w-len(left+right))) + right) lines := []string{status} - for _, log := range logs { + bodyHeight := max(0, h-1) + maxScroll := max(0, len(logs)-bodyHeight) + scroll := m.stdScroll + if scroll < 0 { + scroll = 0 + } + if scroll > maxScroll { + scroll = maxScroll + } + + start := max(0, len(logs)-bodyHeight-scroll) + end := min(len(logs), start+bodyHeight) + visible := logs[start:end] + + for _, log := range visible { var ( tag string body string @@ -225,3 +787,13 @@ func (m Model) renderStdPane(w, h int) []string { return lines } + +func (m Model) stdLogCount() int { + count := 0 + for _, ev := range m.events { + if ev.Type == model.EventTypeProcessStderr || ev.Type == model.EventTypeProcessStdout { + count++ + } + } + return count +} diff --git a/internal/tui/split.go b/internal/tui/split.go index f87c3a7..2379a84 100644 --- a/internal/tui/split.go +++ b/internal/tui/split.go @@ -4,13 +4,13 @@ import "strings" func (m Model) renderAppPane() string { // Constant height offset - constHeightOffset := 1 + constHeightOffset := 2 var ( searchW int = max(0, m.width) searchH int = 1 - reqW int = max(0, int(float64(m.width)*0.55)) + reqW int = max(0, int(float64(m.width)*0.5)) detW int = max(0, m.width-reqW) reqH int = max(0, m.height-constHeightOffset) @@ -68,6 +68,9 @@ func (m Model) renderAppPane() string { screen = append(screen, stdPane...) } + statusBottom := m.renderBottomStatusBar(m.width) + screen = append(screen, statusBottom) + if len(screen) != m.height { return "height of screen does not match terminal height" } diff --git a/internal/tui/style.go b/internal/tui/style.go index 2fe3d85..2a7e9c7 100644 --- a/internal/tui/style.go +++ b/internal/tui/style.go @@ -9,14 +9,17 @@ import ( type Theme struct { Background lipgloss.Style - Header lipgloss.Style - EventHeader lipgloss.Style - StdHeader lipgloss.Style + Header lipgloss.Style + EventHeader lipgloss.Style + EventPaneHeader lipgloss.Style + StdHeader lipgloss.Style - Text lipgloss.Style - TextMuted lipgloss.Style - TextError lipgloss.Style - TextMutedError lipgloss.Style + Text lipgloss.Style + TextMuted lipgloss.Style + TextError lipgloss.Style + TextMutedError lipgloss.Style + RequestSelected lipgloss.Style + HeaderKey lipgloss.Style EventDefault lipgloss.Style EventSession lipgloss.Style @@ -56,6 +59,10 @@ func newTheme() Theme { Bold(true). Foreground(background). Background(blue), + EventPaneHeader: lipgloss.NewStyle(). + Bold(true). + Foreground(background). + Background(green), StdHeader: lipgloss.NewStyle(). Bold(true). Foreground(background). @@ -73,6 +80,13 @@ func newTheme() Theme { TextMutedError: lipgloss.NewStyle(). Foreground(textMuted). Background(backgroundError), + RequestSelected: lipgloss.NewStyle(). + Foreground(background). + Background(blue). + Bold(true), + HeaderKey: lipgloss.NewStyle(). + Foreground(cyan). + Background(background), EventDefault: lipgloss.NewStyle(). Foreground(text). diff --git a/internal/tui/update.go b/internal/tui/update.go index dd276b0..3784163 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -13,6 +13,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.clampPaneScrolls() return m, nil case TickMsg: @@ -27,6 +28,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c", "q": return m, tea.Quit + case "j", "down": + m.moveFocusedVertical(1) + case "k", "up": + m.moveFocusedVertical(-1) + case "tab": + m.moveDetailsTab(1) + case "shift+tab", "backtab": + m.moveDetailsTab(-1) + case "1": + m.setFocusedPane(focusPaneRequests) + case "2": + m.setFocusedPane(focusPaneDetails) + case "3": + m.setFocusedPane(focusPaneEvents) + case "4": + m.setFocusedPane(focusPaneStd) case tea.KeyCtrlR.String(): if m.restarting { return m, nil @@ -38,12 +55,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, restartCmd(m.controls.Restart) case "e": m.showEvents = !m.showEvents + m.ensureFocusedPaneVisible() + m.clampPaneScrolls() case "o": m.showStd = !m.showStd + m.ensureFocusedPaneVisible() + m.clampPaneScrolls() case "/": m.showSearch = true + m.clampPaneScrolls() case "esc": m.showSearch = false + m.clampPaneScrolls() } return m, nil @@ -69,6 +92,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case EventMsg: m.pushEvent(msg.value) m.applyMessage(msg.value) + m.clampPaneScrolls() if m.hasPendingRequests() { return m, tea.Batch(waitForEvent(m.channel), tickCmd()) } @@ -99,6 +123,10 @@ func (m *Model) createRequest(req model.Request) { return } + if len(m.requests) > 0 && m.requestCursor > 0 { + m.requestCursor++ + } + m.requests = append(m.requests, req) // If we passed the max, delete the first one @@ -106,6 +134,8 @@ func (m *Model) createRequest(req model.Request) { if len(m.requests) > maxRequests { m.requests = m.requests[1:] } + + m.clampRequestCursor() } func (m *Model) updateRequest(req model.Request) { @@ -119,6 +149,248 @@ func (m *Model) updateRequest(req model.Request) { } } +func (m *Model) moveRequestCursor(delta int) { + if len(m.requests) == 0 { + m.requestCursor = 0 + m.requestScroll = 0 + return + } + + m.requestCursor += delta + m.clampRequestCursor() + m.ensureRequestCursorVisible() + m.detailsScroll = 0 +} + +func (m *Model) clampRequestCursor() { + if len(m.requests) == 0 { + m.requestCursor = 0 + m.requestScroll = 0 + return + } + + if m.requestCursor < 0 { + m.requestCursor = 0 + } + + if m.requestCursor >= len(m.requests) { + m.requestCursor = len(m.requests) - 1 + } +} + +func (m *Model) ensureRequestCursorVisible() { + viewHeight := m.requestBodyHeight() + if viewHeight <= 0 { + m.requestScroll = 0 + return + } + + maxScroll := max(0, len(m.requests)-viewHeight) + if m.requestScroll < 0 { + m.requestScroll = 0 + } + if m.requestScroll > maxScroll { + m.requestScroll = maxScroll + } + + if m.requestCursor < m.requestScroll { + m.requestScroll = m.requestCursor + } + if m.requestCursor >= m.requestScroll+viewHeight { + m.requestScroll = m.requestCursor - viewHeight + 1 + } + + if m.requestScroll < 0 { + m.requestScroll = 0 + } + if m.requestScroll > maxScroll { + m.requestScroll = maxScroll + } +} + +func (m *Model) moveDetailsTab(delta int) { + count := len(detailsTabNames) + if count == 0 { + m.detailsTab = 0 + m.detailsScroll = 0 + return + } + + m.detailsTab = (m.detailsTab + delta) % count + if m.detailsTab < 0 { + m.detailsTab += count + } + m.detailsScroll = 0 + m.clampPaneScrolls() +} + +func (m *Model) moveFocusedVertical(delta int) { + switch m.focusedPane { + case focusPaneRequests: + m.moveRequestCursor(delta) + case focusPaneDetails: + m.detailsScroll += delta + case focusPaneEvents: + if delta > 0 { + m.eventsScroll = max(0, m.eventsScroll-delta) + } else { + m.eventsScroll += -delta + } + case focusPaneStd: + if delta > 0 { + m.stdScroll = max(0, m.stdScroll-delta) + } else { + m.stdScroll += -delta + } + } + + m.clampPaneScrolls() +} + +func (m *Model) setFocusedPane(pane int) { + if !m.canFocusPane(pane) { + return + } + m.focusedPane = pane +} + +func (m *Model) canFocusPane(pane int) bool { + switch pane { + case focusPaneRequests, focusPaneDetails: + return true + case focusPaneEvents: + return m.showEvents + case focusPaneStd: + return m.showStd + default: + return false + } +} + +func (m *Model) ensureFocusedPaneVisible() { + if m.canFocusPane(m.focusedPane) { + return + } + + if m.canFocusPane(focusPaneDetails) { + m.focusedPane = focusPaneDetails + return + } + + m.focusedPane = focusPaneRequests +} + +func (m *Model) clampPaneScrolls() { + if m.requestScroll < 0 { + m.requestScroll = 0 + } + if m.detailsScroll < 0 { + m.detailsScroll = 0 + } + if m.eventsScroll < 0 { + m.eventsScroll = 0 + } + if m.stdScroll < 0 { + m.stdScroll = 0 + } + + m.ensureRequestCursorVisible() + + maxDetails := m.maxDetailsScroll() + if m.detailsScroll > maxDetails { + m.detailsScroll = maxDetails + } + + maxEvents := m.maxEventsScroll() + if m.eventsScroll > maxEvents { + m.eventsScroll = maxEvents + } + + maxStd := m.maxStdScroll() + if m.stdScroll > maxStd { + m.stdScroll = maxStd + } +} + +func (m Model) panelHeights() (requestHeight, detailsHeight, eventsHeight, stdHeight int) { + const constHeightOffset = 1 + + requestHeight = max(0, m.height-constHeightOffset) + detailsHeight = max(0, m.height-constHeightOffset) + eventsHeight = max(0, int(float64(m.height)*0.2)) + stdHeight = max(0, int(float64(m.height)*0.2)) + + if m.showSearch { + requestHeight = max(0, requestHeight-1) + detailsHeight = max(0, detailsHeight-1) + } + + if m.showEvents { + requestHeight = max(0, requestHeight-eventsHeight) + detailsHeight = max(0, detailsHeight-eventsHeight) + } + + if m.showStd { + requestHeight = max(0, requestHeight-stdHeight) + detailsHeight = max(0, detailsHeight-stdHeight) + } + + return requestHeight, detailsHeight, eventsHeight, stdHeight +} + +func (m Model) requestBodyHeight() int { + requestHeight, _, _, _ := m.panelHeights() + return max(0, requestHeight-2) +} + +func (m Model) detailsBodyHeight() int { + _, detailsHeight, _, _ := m.panelHeights() + return max(0, detailsHeight-2) +} + +func (m Model) detailsPaneWidth() int { + requestWidth := max(0, int(float64(m.width)*0.5)) + return max(0, m.width-requestWidth) +} + +func (m Model) maxDetailsScroll() int { + bodyHeight := m.detailsBodyHeight() + if bodyHeight <= 0 { + return 0 + } + + total := m.detailsContentLineCount(m.detailsPaneWidth()) + return max(0, total-bodyHeight) +} + +func (m Model) maxEventsScroll() int { + if !m.showEvents { + return 0 + } + + _, _, eventsHeight, _ := m.panelHeights() + bodyHeight := max(0, eventsHeight-1) + if bodyHeight <= 0 { + return 0 + } + + return max(0, m.eventCount()-bodyHeight) +} + +func (m Model) maxStdScroll() int { + if !m.showStd { + return 0 + } + + _, _, _, stdHeight := m.panelHeights() + bodyHeight := max(0, stdHeight-1) + if bodyHeight <= 0 { + return 0 + } + + return max(0, m.stdLogCount()-bodyHeight) +} + func (m Model) hasPendingRequests() bool { // Traverse backward to be a bit more efficient, the most recent requests are more // like to be pending.