diff --git a/AGENTS.md b/AGENTS.md index f392049..dae871f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,27 @@ Remember to update the examples in the README when you change, add or remove CLI Some HEY API endpoints return 204 or incomplete data via JSON, but the full HTML content is available by scraping the edit page (e.g., `/calendar/days/{date}/journal_entry/edit` contains the Trix editor hidden input with full HTML). When an API endpoint returns incomplete data, check the corresponding web page for the full content. The `internal/htmlutil` package provides `ToText` (HTML→plain text), `ExtractImageURLs`, and `ParseTopicEntriesHTML` shared by both CLI and TUI. HEY uses Trix editor with `
` for attachments — image URLs in those attributes are relative paths requiring authentication via `sdk.Get`. +### TUI structure + +The TUI uses the `sectionView` interface pattern. Each top-level section (Mail, Calendar, Journal) implements `sectionView` and owns its data, fetch commands, key handling, rendering, and help bindings. The main model delegates to the active view. + +| File | What it contains | +|------|-----------------| +| `section_view.go` | `sectionView` interface + `viewContext` shared dependencies | +| `tui.go` | Core model, `Update` router, `View`, key routing, shared utilities | +| `mail.go` | `mailView` — boxes, postings, topic threads, posting actions, entry rendering | +| `calendar.go` | `calendarView` — calendars, recordings, recording detail | +| `journal.go` | `journalView` — date navigation, journal entries | +| `nav.go` | Header rendering, section/box/calendar/journal nav items, shortcuts | +| `content.go` | `contentList` (posting list) and `recordingList` (recording list) | +| `loading.go` | Hourglass loading animation | +| `styles.go` | Colors, lipgloss styles, error display | +| `help.go` | Help bar at screen bottom | +| `kitty.go` | Kitty graphics protocol for inline images | +| `html.go` | Thin wrappers around `htmlutil` | + +To add a new section: implement the `sectionView` interface in a new file, add a field and constructor call in `newModel`, and add a case in `switchSection`. + ### Inline images in the TUI The TUI renders inline images using the Kitty graphics protocol's Unicode Placeholder extension (`internal/tui/kitty.go`). This works because Bubble Tea's cell-based renderer corrupts raw APC escape sequences, but Unicode placeholders are regular text that survives rendering. The approach has three steps: diff --git a/internal/tui/nav.go b/internal/tui/nav.go index 67796b5..a7e8753 100644 --- a/internal/tui/nav.go +++ b/internal/tui/nav.go @@ -163,25 +163,125 @@ func renderRule(width int, label string) string { // renderNavRow draws a row of nav items with the selected one bolded. // If centered is true, the row is horizontally centered within width. +// When items overflow the available width, the row scrolls horizontally +// to keep the selected item visible and shows ‹/› indicators. func renderNavRow(items []navItem, selected int, focused bool, width int, centered bool) string { - var parts []string + const sep = " " + sepW := lipgloss.Width(sep) + + // Pre-render each item and measure its display width. + type rendered struct { + str string + w int + } + all := make([]rendered, len(items)) + totalW := 0 for i, item := range items { text := item.label if item.icon != "" { text = item.icon + " " + text } + var s string if i == selected { style := lipgloss.NewStyle().Bold(true) if focused { style = style.Foreground(colorPrimary) } - parts = append(parts, style.Render(text)) + s = style.Render(text) } else { - parts = append(parts, lipgloss.NewStyle().Foreground(colorMuted).Render(text)) + s = lipgloss.NewStyle().Foreground(colorMuted).Render(text) + } + w := lipgloss.Width(s) + all[i] = rendered{s, w} + totalW += w + } + totalW += sepW * max(len(items)-1, 0) // separators + + // If everything fits, no scrolling needed. + if totalW <= width { + parts := make([]string, len(all)) + for i, r := range all { + parts[i] = r.str + } + row := strings.Join(parts, sep) + if centered { + rowWidth := lipgloss.Width(row) + pad := max((width-rowWidth)/2, 0) + return strings.Repeat(" ", pad) + row } + return row } - row := strings.Join(parts, " ") + + // Scrolling: find the largest window of items around `selected` that fits. + leftArrow := lipgloss.NewStyle().Foreground(colorMuted).Render("‹ ") + rightArrow := lipgloss.NewStyle().Foreground(colorMuted).Render(" ›") + arrowW := lipgloss.Width(leftArrow) // both arrows have the same width + + // Start with the selected item and expand outward. + lo, hi := selected, selected + usedW := all[selected].w + + for { + expandedLeft, expandedRight := false, false + + // Try expanding left. + if lo > 0 { + need := sepW + all[lo-1].w + reserveR := 0 + if hi < len(items)-1 { + reserveR = arrowW + } + reserveL := 0 + if lo-1 > 0 { + reserveL = arrowW + } + if usedW+need+reserveL+reserveR <= width { + lo-- + usedW += need + expandedLeft = true + } + } + + // Try expanding right. + if hi < len(items)-1 { + need := sepW + all[hi+1].w + reserveL := 0 + if lo > 0 { + reserveL = arrowW + } + reserveR := 0 + if hi+1 < len(items)-1 { + reserveR = arrowW + } + if usedW+need+reserveL+reserveR <= width { + hi++ + usedW += need + expandedRight = true + } + } + + if !expandedLeft && !expandedRight { + break + } + } + + // Build the visible row. + var b strings.Builder + if lo > 0 { + b.WriteString(leftArrow) + } + for i := lo; i <= hi; i++ { + if i > lo { + b.WriteString(sep) + } + b.WriteString(all[i].str) + } + if hi < len(items)-1 { + b.WriteString(rightArrow) + } + + row := b.String() if centered { rowWidth := lipgloss.Width(row) pad := max((width-rowWidth)/2, 0)