From 20155873e60bcb6e2c9c3d2a7ec809362df7764e Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 3 Apr 2026 14:31:30 +0200 Subject: [PATCH 1/2] Explain TUI structure --- AGENTS.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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: From 08433bfaf06b2c0a1de44acee44218443b6cbe7b Mon Sep 17 00:00:00 2001 From: "Stanko K.R." Date: Fri, 3 Apr 2026 14:36:44 +0200 Subject: [PATCH 2/2] Add horizontal scroll to each ribbon --- internal/tui/nav.go | 108 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 4 deletions(-) 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)