commit 52795946b0d56fe44ea2ca60c214d9ceaca2410b
parent 66f6579dce2379d7e3805a7452f3a43d23d9b3fa
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:
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)
}