commit af5e719847f5e00a26546ef8c1d6fb38678d5c69
parent b51dbdcd9567bf531475fed00be9647e35e00658
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.
Diffstat:
9 files changed, 342 insertions(+), 7 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,85 @@
+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")
+ }
+}
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 sideways"),
+ ),
+ Right: key.NewBinding(
+ key.WithKeys("l", "shift+right"),
+ key.WithHelp("h/l", "scroll sideways"),
+ ),
}
}
diff --git a/internal/tui/layout.go b/internal/tui/layout.go
@@ -3,7 +3,11 @@ package tui
import "github.com/charmbracelet/lipgloss"
func (m *portfolioModel) syncViewport() {
- m.viewport.SetContent(m.detailContent())
+ 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)
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,11 @@ 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
+
width int
height int
now time.Time
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -150,6 +150,17 @@ func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
}
}
+ if m.activeTab == tabProjects {
+ switch {
+ case key.Matches(msg, m.keys.Left):
+ m.scrollDetailX(-detailHorizontalStep)
+ return nil
+ case key.Matches(msg, m.keys.Right):
+ m.scrollDetailX(detailHorizontalStep)
+ return nil
+ }
+ }
+
if m.consumeProjectReadmeBack(msg) {
return nil
}
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -64,6 +64,7 @@ func (m portfolioModel) renderHelpBody(width int, height int) string {
{key: "◀ ▶", desc: "navigate tabs"},
{key: "▲ ▼", desc: "switch list items"},
{key: "shift + ▲ ▼", desc: "scroll item details"},
+ {key: "h l", desc: "scroll wide code sideways"},
{key: "tab", desc: "cycle tabs"},
{key: "1-3", desc: "switch tabs"},
{key: "enter", desc: "open item"},
@@ -180,17 +181,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 +200,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)
}