prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit a7e4f1c00bbc3c512cb109847023200aef42b617
parent af5e719847f5e00a26546ef8c1d6fb38678d5c69
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 08:23:31 +0000

fix(tui): arrow keys pan readme code sideways instead of switching tabs

With a project readme focused, → pans wide code blocks right and ← pans
left; ← at the left edge still backs out of the readme. Arrows no longer
switch tabs while the readme is focused (they still do everywhere else).
h/l and shift+←/→ keep working.

Re-renders of the same view (async readme/markdown results, resizes,
ticks) no longer reset the scroll position; it only resets when the tab
or the selected project/experience changes, or when leaving the readme.

Diffstat:
Minternal/tui/hscroll_test.go | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/keys.go | 6+++---
Minternal/tui/layout.go | 36++++++++++++++++++++++++++++++++++--
Minternal/tui/types.go | 2++
Minternal/tui/update.go | 15++++++++++-----
Minternal/tui/view.go | 5+++--
6 files changed, 174 insertions(+), 12 deletions(-)

diff --git a/internal/tui/hscroll_test.go b/internal/tui/hscroll_test.go @@ -83,3 +83,125 @@ func TestReadmeHorizontalScroll(t *testing.T) { t.Fatal("left no longer backs out of the readme") } } + +// readmeModel returns a model on the projects tab with the wide readme +// rendered and the readme pane focused. +func readmeModel(t *testing.T) portfolioModel { + t.Helper() + m := testModel(t, 100, 40) + id := m.projects[0].ID + m.projectReadmes[id] = wideReadme + m.activateTab(tabProjects) + w := m.projectMarkdownWidth() + rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg) + m = send(t, m, rendered) + m = send(t, m, tea.KeyMsg{Type: tea.KeyEnter}) + if !m.projectReadmeScroll { + t.Fatal("enter did not focus the readme") + } + if m.detailMaxXOffset() <= 0 { + t.Fatalf("detailMaxXOffset = %d for a wide code block", m.detailMaxXOffset()) + } + return m +} + +func send(t *testing.T, m portfolioModel, msg tea.Msg) portfolioModel { + t.Helper() + next, _ := m.Update(msg) + return next.(portfolioModel) +} + +func TestReadmeArrowKeysScrollSideways(t *testing.T) { + m := readmeModel(t) + maxX := m.detailMaxXOffset() + + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + if m.detailXOffset != detailHorizontalStep || m.activeTab != tabProjects { + t.Fatalf("right: x=%d tab=%d", m.detailXOffset, m.activeTab) + } + for i := 0; i < 50; i++ { + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + } + if m.detailXOffset != maxX || m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("right to the end: x=%d (max %d) tab=%d focus=%v", m.detailXOffset, maxX, m.activeTab, m.projectReadmeScroll) + } + if view := ansi.Strip(m.View()); !strings.Contains(view, "END") { + t.Fatalf("END not visible at max offset:\n%s", view) + } + + m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + if m.detailXOffset != maxX-detailHorizontalStep || m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("left: x=%d tab=%d focus=%v", m.detailXOffset, m.activeTab, m.projectReadmeScroll) + } + for m.detailXOffset > 0 { + m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + if m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("left while panned changed tab/focus: x=%d tab=%d", m.detailXOffset, m.activeTab) + } + } + // left at the left edge backs out of the readme, still without a tab change + m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + if m.projectReadmeScroll || m.activeTab != tabProjects { + t.Fatalf("left at x=0: focus=%v tab=%d", m.projectReadmeScroll, m.activeTab) + } + // with the readme unfocused, arrows switch tabs again + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + if m.activeTab != tabAbout { + t.Fatalf("right outside the readme: tab=%d", m.activeTab) + } +} + +func TestReadmeHLAndShiftArrowsScrollEndToEnd(t *testing.T) { + for _, keys := range []struct { + name string + right, left tea.KeyMsg + }{ + {"h/l", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}}, + {"shift+arrows", tea.KeyMsg{Type: tea.KeyShiftRight}, tea.KeyMsg{Type: tea.KeyShiftLeft}}, + } { + m := readmeModel(t) + maxX := m.detailMaxXOffset() + for i := 0; i < 50; i++ { + m = send(t, m, keys.right) + } + if m.detailXOffset != maxX || m.activeTab != tabProjects { + t.Fatalf("%s right: x=%d (max %d) tab=%d", keys.name, m.detailXOffset, maxX, m.activeTab) + } + for i := 0; i < 50; i++ { + m = send(t, m, keys.left) + } + if m.detailXOffset != 0 || m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("%s left: x=%d tab=%d focus=%v", keys.name, m.detailXOffset, m.activeTab, m.projectReadmeScroll) + } + } +} + +func TestReadmeOffsetSurvivesAsyncRerender(t *testing.T) { + m := readmeModel(t) + for i := 0; i < 3; i++ { + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + } + m = send(t, m, tea.KeyMsg{Type: tea.KeyDown}) + x, y := m.detailXOffset, m.viewport.YOffset + if x == 0 { + t.Fatal("did not scroll") + } + + id := m.projects[0].ID + w := m.projectMarkdownWidth() + rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg) + m = send(t, m, rendered) + m = send(t, m, projectReadmeMsg{projectID: id, readme: wideReadme}) + m = send(t, m, tea.WindowSizeMsg{Width: 100, Height: 40}) + m = send(t, m, tickMsg(m.now)) + if m.detailXOffset != x || m.viewport.YOffset != y { + t.Fatalf("re-render reset scroll: x=%d->%d y=%d->%d", x, m.detailXOffset, y, m.viewport.YOffset) + } + + // changing the selected project resets it + m = send(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + m = send(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if m.detailXOffset != 0 || m.viewport.YOffset != 0 { + t.Fatalf("new project kept scroll: x=%d y=%d", m.detailXOffset, m.viewport.YOffset) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go @@ -61,11 +61,11 @@ func defaultKeyMap() keyMap { ), Left: key.NewBinding( key.WithKeys("h", "shift+left"), - key.WithHelp("h/l", "scroll sideways"), + key.WithHelp("←/→ h/l", "scroll readme sideways"), ), Right: key.NewBinding( key.WithKeys("l", "shift+right"), - key.WithHelp("h/l", "scroll sideways"), + key.WithHelp("←/→ h/l", "scroll readme sideways"), ), } } @@ -75,5 +75,5 @@ func (k keyMap) ShortHelp() []key.Binding { } func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown}, {k.Open, k.Back, k.Help, k.Quit}} + return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown, k.Right}, {k.Open, k.Back, k.Help, k.Quit}} } diff --git a/internal/tui/layout.go b/internal/tui/layout.go @@ -2,22 +2,54 @@ package tui import "github.com/charmbracelet/lipgloss" +// detailViewKey identifies what the detail pane is showing. Scroll offsets +// survive re-renders of the same view (async readme/markdown results, window +// resizes, ticks) and only reset when the view itself changes. +type detailViewKey struct { + tab, project, experience int +} + +func (m portfolioModel) currentDetailViewKey() detailViewKey { + return detailViewKey{tab: m.activeTab, project: m.projectIndex, experience: m.experienceIndex} +} + +// syncViewport re-renders the detail pane, keeping the scroll position when +// the same tab/item is still selected. func (m *portfolioModel) syncViewport() { + key := m.currentDetailViewKey() + same := m.detailViewSet && key == m.detailView + m.detailView, m.detailViewSet = key, true + content := m.detailContent() m.viewport.SetContent(content) m.detailLineWidths = lineWidths(content) - m.detailXOffset = 0 - m.viewport.SetXOffset(0) w, h := m.detailSize() m.viewport.Width = max(w, 1) m.viewport.Height = max(h, 1) + + if !same { + m.detailXOffset = 0 + } + m.detailXOffset = min(max(m.detailXOffset, 0), m.detailMaxXOffset()) + m.viewport.SetXOffset(m.detailXOffset) + if m.activeTab == tabAbout && (m.shell.cleared || len(m.shell.history) > 0 || m.shell.input != "") { m.viewport.GotoBottom() return } + if same { + m.viewport.SetYOffset(m.viewport.YOffset) // clamp to the new content + return + } m.viewport.GotoTop() } +// resetViewport re-renders the detail pane scrolled back to the top-left. +func (m *portfolioModel) resetViewport() { + m.detailViewSet = false + m.syncViewport() +} + func (m portfolioModel) detailSize() (int, int) { bodyW, bodyH := m.bodySize() diff --git a/internal/tui/types.go b/internal/tui/types.go @@ -47,6 +47,8 @@ type portfolioModel struct { // horizontal scrolling and the cut-off markers. detailLineWidths []int detailXOffset int + detailView detailViewKey + detailViewSet bool width int height int diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -151,11 +151,16 @@ func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { } if m.activeTab == tabProjects { + // With the readme focused, plain arrows pan sideways instead of + // switching tabs; left at the left edge falls through and backs out. + readme := m.projectReadmeScroll switch { - case key.Matches(msg, m.keys.Left): + case key.Matches(msg, m.keys.Left), + readme && msg.Type == tea.KeyLeft && m.detailXOffset > 0: m.scrollDetailX(-detailHorizontalStep) return nil - case key.Matches(msg, m.keys.Right): + case key.Matches(msg, m.keys.Right), + readme && msg.Type == tea.KeyRight: m.scrollDetailX(detailHorizontalStep) return nil } @@ -209,7 +214,7 @@ func (m *portfolioModel) activateTab(tab int) tea.Cmd { m.activeTab = tab m.projectReadmeScroll = false - m.syncViewport() + m.resetViewport() return m.refreshDataCmd() } @@ -224,7 +229,7 @@ func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool { } m.projectReadmeScroll = false - m.syncViewport() + m.resetViewport() return true } @@ -244,7 +249,7 @@ func (m *portfolioModel) handleOpenKey() tea.Cmd { func (m *portfolioModel) handleBackKey() tea.Cmd { if m.activeTab == tabProjects && m.projectReadmeScroll { m.projectReadmeScroll = false - m.syncViewport() + m.resetViewport() return nil } diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -61,10 +61,11 @@ func (m portfolioModel) renderHelpBody(width int, height int) string { key string desc string }{ - {key: "◀ ▶", desc: "navigate tabs"}, + {key: "◀ ▶", desc: "navigate tabs (pan sideways in a readme)"}, {key: "▲ ▼", desc: "switch list items"}, {key: "shift + ▲ ▼", desc: "scroll item details"}, - {key: "h l", desc: "scroll wide code sideways"}, + {key: "◀ ▶ h l", desc: "scroll wide code sideways in a readme"}, + {key: "esc", desc: "leave the readme (or ◀ at the left edge)"}, {key: "tab", desc: "cycle tabs"}, {key: "1-3", desc: "switch tabs"}, {key: "enter", desc: "open item"},