prime8dev

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

commit 1661e1c4c38f73d75ee352006d0515d69b736fa2
parent 29e428f9cc69af9dcb8bdb491256feb12e1605db
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 08:01:05 +0000

feat: show all-time wakatime total, hide daily

WakaTime is back on, but only the all-time "N+ hrs coded" total in the
web status bar and the TUI footer. The daily breakdown (wakatime --week)
stays hidden behind config.WakaTimeWeeklyEnabled. /api/wakatime and the
TUI now fetch only all_time_since_today unless weekly is enabled.

Diffstat:
Mcmd/server/handlers/curl.go | 7+++----
Dcmd/server/handlers/curl_test.go | 36------------------------------------
Dinternal/tui/hscroll.go | 61-------------------------------------------------------------
Dinternal/tui/hscroll_test.go | 207-------------------------------------------------------------------------------
Minternal/tui/keys.go | 12+-----------
Minternal/tui/layout.go | 38+-------------------------------------
Minternal/tui/markdown.go | 5++---
Dinternal/tui/markdown_wide.go | 156-------------------------------------------------------------------------------
Minternal/tui/types.go | 7-------
Minternal/tui/update.go | 22+++-------------------
Minternal/tui/view.go | 13+++++--------
11 files changed, 15 insertions(+), 549 deletions(-)

diff --git a/cmd/server/handlers/curl.go b/cmd/server/handlers/curl.go @@ -7,10 +7,10 @@ import ( "strings" "sync" "time" + "unicode/utf8" "git.prime8.dev/moncheeta/prime8dev/internal/config" "git.prime8.dev/moncheeta/prime8dev/internal/utils" - "github.com/charmbracelet/x/ansi" ) const ( @@ -22,7 +22,7 @@ const ( ansiBlue = "\x1b[38;5;110m" ) -var curlLogo = sync.OnceValue(func() string { return utils.LogoASCII(curlLogoWidth, true) }) +var curlLogo = sync.OnceValue(func() string { return utils.LogoASCII(curlLogoWidth, false) }) // isTerminalClient reports whether the request comes from a CLI http client. func isTerminalClient(r *http.Request) bool { @@ -84,8 +84,7 @@ func terminalFetch(about config.About, job *config.Experience) string { if i < len(logo) { l = logo[i] } - // pad by visible width: the logo lines carry truecolor escapes - b.WriteString(l + ansiReset + strings.Repeat(" ", max(0, curlLogoWidth-ansi.StringWidth(l))) + " ") + b.WriteString(l + strings.Repeat(" ", max(0, curlLogoWidth-utf8.RuneCountInString(l))) + " ") if i < len(lines) { b.WriteString(lines[i]) } diff --git a/cmd/server/handlers/curl_test.go b/cmd/server/handlers/curl_test.go @@ -1,36 +0,0 @@ -package handlers - -import ( - "os" - "strings" - "testing" - - "git.prime8.dev/moncheeta/prime8dev/internal/config" - "github.com/charmbracelet/x/ansi" -) - -func TestTerminalFetchColoredLogoAligned(t *testing.T) { - if err := os.Chdir("../../.."); err != nil { - t.Fatal(err) - } - out := terminalFetch(config.GetAbout(), config.CurrentJob(config.GetExperience())) - // aic picks its color level from COLORTERM at init (the Docker image sets - // COLORTERM=truecolor); without it the logo is plain but must still align. - if strings.Contains(os.Getenv("COLORTERM"), "truecolor") && !strings.Contains(out, "\x1b[38;2;") { - t.Fatal("logo is not truecolor") - } - lines := strings.Split(strings.Trim(out, "\n"), "\n") - for i, l := range lines { - plain := ansi.Strip(l) - if ansi.StringWidth(plain) <= curlLogoWidth { - continue - } - // the info column starts right after the padded logo and 3 spaces - if got := []rune(plain)[curlLogoWidth : curlLogoWidth+3]; string(got) != " " { - t.Errorf("line %d misaligned: %q", i, plain) - } - } - if !strings.Contains(ansi.Strip(lines[0]), "moncheeta@prime8") { - t.Errorf("first line missing title: %q", ansi.Strip(lines[0])) - } -} diff --git a/internal/tui/hscroll.go b/internal/tui/hscroll.go @@ -1,61 +0,0 @@ -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 @@ -1,207 +0,0 @@ -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,8 +13,6 @@ type keyMap struct { Quit key.Binding PageDown key.Binding PageUp key.Binding - Left key.Binding - Right key.Binding } func defaultKeyMap() keyMap { @@ -59,14 +57,6 @@ 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"), - ), } } @@ -75,5 +65,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.Right}, {k.Open, k.Back, k.Help, k.Quit}} + return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown}, {k.Open, k.Back, k.Help, k.Quit}} } diff --git a/internal/tui/layout.go b/internal/tui/layout.go @@ -2,54 +2,18 @@ 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.viewport.SetContent(m.detailContent()) 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,13 +26,12 @@ func renderMarkdown(markdown string, width int) string { width = max(width, 20) renderWidth := markdownRenderWidth(markdown, width) - source, wide := extractWideBlocks(markdown, renderWidth) - out := termmarkdown.Render(source, renderWidth, 0) + out := termmarkdown.Render(markdown, renderWidth, 0) if len(out) == 0 { return markdown } - return restoreWideBlocks(styleRenderedMarkdown(strings.TrimRight(string(out), "\n")), wide) + return styleRenderedMarkdown(strings.TrimRight(string(out), "\n")) } func styleRenderedMarkdown(rendered string) string { diff --git a/internal/tui/markdown_wide.go b/internal/tui/markdown_wide.go @@ -1,156 +0,0 @@ -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,13 +43,6 @@ 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,22 +150,6 @@ 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 } @@ -214,7 +198,7 @@ func (m *portfolioModel) activateTab(tab int) tea.Cmd { m.activeTab = tab m.projectReadmeScroll = false - m.resetViewport() + m.syncViewport() return m.refreshDataCmd() } @@ -229,7 +213,7 @@ func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool { } m.projectReadmeScroll = false - m.resetViewport() + m.syncViewport() return true } @@ -249,7 +233,7 @@ func (m *portfolioModel) handleOpenKey() tea.Cmd { func (m *portfolioModel) handleBackKey() tea.Cmd { if m.activeTab == tabProjects && m.projectReadmeScroll { m.projectReadmeScroll = false - m.resetViewport() + m.syncViewport() return nil } diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -61,11 +61,9 @@ func (m portfolioModel) renderHelpBody(width int, height int) string { key string desc string }{ - {key: "◀ ▶", desc: "navigate tabs (pan sideways in a readme)"}, + {key: "◀ ▶", desc: "navigate tabs"}, {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"}, @@ -182,18 +180,17 @@ 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), detail) + detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), m.viewport.View()) return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane) } listPane := renderPanelWithStyle(panelStyleWithoutBottom(panelStyle), bodyW, stackTop, listContent(bodyW-4, stackTop-2)) - detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, detail) + detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, m.viewport.View()) return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane) } @@ -201,11 +198,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, detail) + detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, m.viewport.View()) return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) } listPane := renderPanelWithStyle(panelStyleWithoutRight(panelStyle), listW, height, listContent(listW-4, height-2)) - detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, detail) + detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, m.viewport.View()) return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) }