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:
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"},