commit 6f294736dc05a6c8013539cb28b8b63b3471fb54
parent d33367bef3ef2e5db445ad2d41a7ec8eb25d7cd4
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 08:03:54 +0000
feat(tui): unwrapped wide code blocks with horizontal scroll in readmes
Fenced and indented code blocks wider than the readme pane are kept
verbatim instead of being wrapped by the markdown renderer, so ascii
diagrams stay intact. h/l (or shift+←/→) pan the detail pane sideways
on the projects tab; lines cut at the edge get a dim › / ‹ marker.
Prose still wraps, narrow blocks keep their highlighting, and ← still
backs out of the readme.
With a readme focused, → and ← pan wide code sideways instead of
switching tabs (← at the left edge still backs out), and re-renders
of the same view keep the scroll position.
Diffstat:
9 files changed, 509 insertions(+), 12 deletions(-)
diff --git a/internal/tui/hscroll.go b/internal/tui/hscroll.go
@@ -0,0 +1,61 @@
+package tui
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+const detailHorizontalStep = 8
+
+var cutMarkerStyle = dimStyle
+
+func lineWidths(content string) []int {
+ lines := strings.Split(content, "\n")
+ widths := make([]int, len(lines))
+ for i, l := range lines {
+ widths[i] = ansi.StringWidth(l)
+ }
+ return widths
+}
+
+func (m portfolioModel) detailMaxXOffset() int {
+ longest := 0
+ for _, w := range m.detailLineWidths {
+ longest = max(longest, w)
+ }
+ return max(longest-m.viewport.Width, 0)
+}
+
+// scrollDetailX pans the detail viewport sideways; only wide preformatted
+// blocks are wider than the pane, so prose never moves out of view for long.
+func (m *portfolioModel) scrollDetailX(delta int) {
+ m.detailXOffset = min(max(m.detailXOffset+delta, 0), m.detailMaxXOffset())
+ m.viewport.SetXOffset(m.detailXOffset)
+}
+
+// detailViewWithCutMarkers renders the viewport and marks lines that continue
+// past the right edge with › (and past the left edge with ‹ when panned).
+func (m portfolioModel) detailViewWithCutMarkers() string {
+ view := m.viewport.View()
+ w := m.viewport.Width
+ if w < 4 || m.detailMaxXOffset() == 0 {
+ return view
+ }
+ lines := strings.Split(view, "\n")
+ for i, line := range lines {
+ idx := m.viewport.YOffset + i
+ if idx >= len(m.detailLineWidths) {
+ break
+ }
+ full := m.detailLineWidths[idx]
+ if full > m.detailXOffset+w {
+ line = ansi.Truncate(line, w-1, "") + cutMarkerStyle.Render("›")
+ }
+ if m.detailXOffset > 0 && full > m.detailXOffset {
+ line = cutMarkerStyle.Render("‹") + ansi.Cut(line, 1, w)
+ }
+ lines[i] = line
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/tui/hscroll_test.go b/internal/tui/hscroll_test.go
@@ -0,0 +1,207 @@
+package tui
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/x/ansi"
+)
+
+const wideReadme = "# janitor\n\nsome prose that is long enough to wrap around the pane width for sure, yes indeed it is long. " +
+ "some prose that is long enough to wrap around the pane width for sure, yes indeed it is long.\n\n" +
+ "```\n" +
+ "+--------+ +-------------------------+ +--------------------+ +----------+ +-------+ END\n" +
+ "| client | ---> | server with a long name | ---> | queue | ---> | db |\n" +
+ "```\n\nnarrow:\n\n```\nok\n```\n"
+
+func TestWideBlocksDoNotWrap(t *testing.T) {
+ out := ansi.Strip(renderMarkdown(wideReadme, 40))
+ if !strings.Contains(out, "┃ +--------+ +-------------------------+ +--------------------+ +----------+ +-------+ END") {
+ t.Fatalf("wide block wrapped:\n%s", out)
+ }
+ for _, l := range strings.Split(out, "\n") {
+ if strings.Contains(l, "prose") && ansi.StringWidth(l) > 40 {
+ t.Fatalf("prose did not wrap: %q", l)
+ }
+ }
+ if !strings.Contains(out, "┃ ok") {
+ t.Fatalf("narrow block lost:\n%s", out)
+ }
+ if strings.Contains(out, wideBlockToken) {
+ t.Fatalf("placeholder leaked:\n%s", out)
+ }
+}
+
+func TestIndentedListContinuationIsNotCode(t *testing.T) {
+ md := "- item\n\n continuation of the list item that is quite long and keeps going past forty columns\n"
+ src, blocks := extractWideBlocks(md, 40)
+ if len(blocks) != 0 || src != md {
+ t.Fatalf("list continuation extracted: %q %v", src, blocks)
+ }
+}
+
+func TestReadmeHorizontalScroll(t *testing.T) {
+ 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)
+ next, _ := m.Update(rendered)
+ m = next.(portfolioModel)
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
+
+ view := ansi.Strip(m.View())
+ if !strings.Contains(view, "›") {
+ t.Fatalf("no right cut marker:\n%s", view)
+ }
+ if strings.Contains(view, "END") {
+ t.Fatal("END visible before scrolling")
+ }
+ for i := 0; i < 10; i++ {
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}})
+ }
+ view = ansi.Strip(m.View())
+ if !strings.Contains(view, "END") || !strings.Contains(view, "‹") {
+ t.Fatalf("scrolling right did not reveal END:\n%s", view)
+ }
+ for _, l := range strings.Split(view, "\n") {
+ if ansi.StringWidth(l) > 100 {
+ t.Fatalf("line overflows: %d", ansi.StringWidth(l))
+ }
+ }
+ for i := 0; i < 20; i++ {
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}})
+ }
+ if m.detailXOffset != 0 {
+ t.Fatalf("x offset = %d", m.detailXOffset)
+ }
+ // left still leaves readme focus
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyLeft})
+ if m.projectReadmeScroll {
+ 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
@@ -13,6 +13,8 @@ type keyMap struct {
Quit key.Binding
PageDown key.Binding
PageUp key.Binding
+ Left key.Binding
+ Right key.Binding
}
func defaultKeyMap() keyMap {
@@ -57,6 +59,14 @@ func defaultKeyMap() keyMap {
key.WithKeys("pgup", "b", "shift+up"),
key.WithHelp("pgup", "scroll up"),
),
+ Left: key.NewBinding(
+ key.WithKeys("h", "shift+left"),
+ key.WithHelp("←/→ h/l", "scroll readme sideways"),
+ ),
+ Right: key.NewBinding(
+ key.WithKeys("l", "shift+right"),
+ key.WithHelp("←/→ h/l", "scroll readme sideways"),
+ ),
}
}
@@ -65,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,18 +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() {
- m.viewport.SetContent(m.detailContent())
+ 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)
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/markdown.go b/internal/tui/markdown.go
@@ -26,12 +26,13 @@ func renderMarkdown(markdown string, width int) string {
width = max(width, 20)
renderWidth := markdownRenderWidth(markdown, width)
- out := termmarkdown.Render(markdown, renderWidth, 0)
+ source, wide := extractWideBlocks(markdown, renderWidth)
+ out := termmarkdown.Render(source, renderWidth, 0)
if len(out) == 0 {
return markdown
}
- return styleRenderedMarkdown(strings.TrimRight(string(out), "\n"))
+ return restoreWideBlocks(styleRenderedMarkdown(strings.TrimRight(string(out), "\n")), wide)
}
func styleRenderedMarkdown(rendered string) string {
diff --git a/internal/tui/markdown_wide.go b/internal/tui/markdown_wide.go
@@ -0,0 +1,156 @@
+package tui
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+// Preformatted blocks (code, ascii diagrams) wider than the pane are pulled
+// out before markdown rendering and put back verbatim afterwards, so they
+// overflow (and scroll horizontally) instead of wrapping. Narrow blocks keep
+// the renderer's own styling and highlighting.
+
+const wideBlockToken = "PRIME8WIDEBLOCK"
+
+var (
+ fenceOpenRE = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})")
+ listItemRE = regexp.MustCompile(`^\s*([-*+]|\d+[.)])\s`)
+ wideTokenRE = regexp.MustCompile(`^\s*` + wideBlockToken + `(\d+)\s*$`)
+ codeGutter = "┃ "
+ codeGutterLen = ansi.StringWidth(codeGutter)
+)
+
+// extractWideBlocks replaces every preformatted block whose longest line does
+// not fit in width with a placeholder paragraph.
+func extractWideBlocks(markdown string, width int) (string, [][]string) {
+ lines := strings.Split(markdown, "\n")
+ out := make([]string, 0, len(lines))
+ var blocks [][]string
+
+ emit := func(original []string, body []string) {
+ if blockWidth(body)+codeGutterLen <= width {
+ out = append(out, original...)
+ return
+ }
+ out = append(out, "", fmt.Sprintf("%s%d", wideBlockToken, len(blocks)), "")
+ blocks = append(blocks, body)
+ }
+
+ for i := 0; i < len(lines); i++ {
+ line := lines[i]
+
+ if m := fenceOpenRE.FindStringSubmatch(line); m != nil {
+ fence := m[1]
+ end := -1
+ for j := i + 1; j < len(lines); j++ {
+ t := strings.TrimSpace(lines[j])
+ if strings.HasPrefix(t, fence[:3]) && strings.Trim(t, fence[:1]) == "" && len(t) >= len(fence) {
+ end = j
+ break
+ }
+ }
+ if end < 0 {
+ out = append(out, lines[i:]...)
+ break
+ }
+ emit(lines[i:end+1], expandTabs(lines[i+1:end]))
+ i = end
+ continue
+ }
+
+ if isIndentedCode(line) && precededByBlankParagraph(lines, i) {
+ j := i
+ for j < len(lines) && (isIndentedCode(lines[j]) || strings.TrimSpace(lines[j]) == "") {
+ j++
+ }
+ for j > i && strings.TrimSpace(lines[j-1]) == "" {
+ j--
+ }
+ body := make([]string, 0, j-i)
+ for _, l := range lines[i:j] {
+ l = expandTabs([]string{l})[0]
+ if len(l) >= 4 {
+ l = l[4:]
+ } else {
+ l = ""
+ }
+ body = append(body, l)
+ }
+ emit(lines[i:j], body)
+ i = j - 1
+ continue
+ }
+
+ out = append(out, line)
+ }
+
+ return strings.Join(out, "\n"), blocks
+}
+
+func isIndentedCode(line string) bool {
+ return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
+}
+
+// precededByBlankParagraph reports whether an indented line starts a code
+// block rather than continuing a list item.
+func precededByBlankParagraph(lines []string, i int) bool {
+ if i == 0 {
+ return true
+ }
+ if strings.TrimSpace(lines[i-1]) != "" {
+ return false
+ }
+ for j := i - 1; j >= 0; j-- {
+ if strings.TrimSpace(lines[j]) == "" {
+ continue
+ }
+ return !listItemRE.MatchString(lines[j]) && !isIndentedCode(lines[j])
+ }
+ return true
+}
+
+func expandTabs(lines []string) []string {
+ out := make([]string, len(lines))
+ for i, l := range lines {
+ out[i] = strings.ReplaceAll(l, "\t", " ")
+ }
+ return out
+}
+
+func blockWidth(lines []string) int {
+ w := 0
+ for _, l := range lines {
+ w = max(w, ansi.StringWidth(l))
+ }
+ return w
+}
+
+// restoreWideBlocks swaps the placeholders back for the verbatim block lines.
+func restoreWideBlocks(rendered string, blocks [][]string) string {
+ if len(blocks) == 0 {
+ return rendered
+ }
+ lines := strings.Split(rendered, "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ plain := ansi.Strip(line)
+ m := wideTokenRE.FindStringSubmatch(plain)
+ if m == nil {
+ out = append(out, line)
+ continue
+ }
+ var idx int
+ if _, err := fmt.Sscanf(m[1], "%d", &idx); err != nil || idx >= len(blocks) {
+ out = append(out, line)
+ continue
+ }
+ indent := plain[:len(plain)-len(strings.TrimLeft(plain, " "))]
+ for _, l := range blocks[idx] {
+ out = append(out, indent+dimStyle.Render(codeGutter)+rowStyle.Render(l))
+ }
+ }
+ return strings.Join(out, "\n")
+}
diff --git a/internal/tui/types.go b/internal/tui/types.go
@@ -43,6 +43,13 @@ type portfolioModel struct {
shell shellState
+ // detailLineWidths holds the visible width of each viewport line, for
+ // horizontal scrolling and the cut-off markers.
+ detailLineWidths []int
+ detailXOffset int
+ detailView detailViewKey
+ detailViewSet bool
+
width int
height int
now time.Time
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -150,6 +150,22 @@ 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),
+ readme && msg.Type == tea.KeyLeft && m.detailXOffset > 0:
+ m.scrollDetailX(-detailHorizontalStep)
+ return nil
+ case key.Matches(msg, m.keys.Right),
+ readme && msg.Type == tea.KeyRight:
+ m.scrollDetailX(detailHorizontalStep)
+ return nil
+ }
+ }
+
if m.consumeProjectReadmeBack(msg) {
return nil
}
@@ -198,7 +214,7 @@ func (m *portfolioModel) activateTab(tab int) tea.Cmd {
m.activeTab = tab
m.projectReadmeScroll = false
- m.syncViewport()
+ m.resetViewport()
return m.refreshDataCmd()
}
@@ -213,7 +229,7 @@ func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool {
}
m.projectReadmeScroll = false
- m.syncViewport()
+ m.resetViewport()
return true
}
@@ -233,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,9 +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 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"},
@@ -180,17 +182,18 @@ func (m portfolioModel) renderBody(width int, height int) string {
func (m portfolioModel) renderProjectsBody(bodyW int, height int) string {
listFocused := !m.projectReadmeScroll
listContent := func(w, h int) string { return m.projectListContent(w, h) }
+ detail := m.detailViewWithCutMarkers()
if bodyW < 72 {
stackTop := max(height/2, 3)
stackBottom := max(height-stackTop, 1)
if listFocused {
listPane := renderPanelWithStyle(panelStyleSharedBottom(panelActiveStyle), bodyW, stackTop+1, listContent(bodyW-4, stackTop-2))
- detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), detail)
return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane)
}
listPane := renderPanelWithStyle(panelStyleWithoutBottom(panelStyle), bodyW, stackTop, listContent(bodyW-4, stackTop-2))
- detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, detail)
return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane)
}
@@ -198,11 +201,11 @@ func (m portfolioModel) renderProjectsBody(bodyW int, height int) string {
detailW := max(bodyW-listW, 12)
if listFocused {
listPane := renderPanelWithStyle(panelStyleSharedRight(panelActiveStyle), listW+1, height, listContent(listW-4, height-2))
- detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, detail)
return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane)
}
listPane := renderPanelWithStyle(panelStyleWithoutRight(panelStyle), listW, height, listContent(listW-4, height-2))
- detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, m.viewport.View())
+ detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, detail)
return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane)
}