From 0b1db4e47e71cf977016e7afe9da2d5087756117 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Wed, 5 Aug 2026 15:11:30 +0530 Subject: [PATCH 1/3] perf(tui): precompute session-scoped style set to eliminate per-frame allocations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme is fixed at startup and never changes, yet every frame built ~300-400 fresh lipgloss.Style structs via styleWithFg / lipgloss.NewStyle() calls scattered across the hot render paths. Added a themeStyles struct (tui/styles.go) with 26 pre-built Style fields, populated once in NewModel via newThemeStyles(theme) and stored as m.styles. Threaded through internal structs (treePaneInput, splitTreeInput, viewerParams) so the hot per-row renderers read a field instead of allocating. Replaced all static styleWithFg / lipgloss.NewStyle() calls in: - tui/model.go — renderHeader, renderStatusBar, renderViewerStatusBar, viewLoading, viewError, separator line - tui/filetree.go — renderTreeHeader, renderFilterBar, renderSplitDivider, formatFileNodeLine, renderNameWithHighlight - tui/layers.go — renderCommandBar, highlightInstruction, formatLayerLine, renderSizeColumn - tui/fileview.go — renderViewerLine, renderViewerSearchBar, overlayCursor, gutter, truncation/binary/empty notices Dynamic styles (per-row diff color, delta color, selected-row bg, search-current highlight) remain inline as their color varies per call. Updated all test call sites to pass themeStyles{} (zero value is correct for structural tests that check content not color). --- CHANGELOG.md | 5 +++ tui/filetree.go | 76 +++++++++++++++++++----------------- tui/filetree_test.go | 10 ++--- tui/fileview.go | 51 ++++++++++++------------ tui/layers.go | 38 +++++++++--------- tui/layers_test.go | 16 ++++---- tui/model.go | 92 +++++++++++++++++++++---------------------- tui/model_test.go | 10 ++--- tui/styles.go | 93 ++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 246 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1bea27..fb12da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 per candidate position when scanning each visible line for matches. The inner scan now uses `strings.Index` on the lowercased line tail, reducing allocation pressure during active search through large files. +- All theme-derived lipgloss styles are now built once at startup into a + session-scoped style set and reused every frame. The theme is fixed for the + session, so the ~300–400 per-frame style allocations that were rebuilding + identical structs on every redraw are eliminated. Reduces GC pressure + noticeably on low-power machines and slow terminals. ## [v1.6.0] - 2026-07-28 diff --git a/tui/filetree.go b/tui/filetree.go index 9a22732..d124586 100644 --- a/tui/filetree.go +++ b/tui/filetree.go @@ -12,11 +12,12 @@ import ( "github.com/deveshctl/layerx/image" ) -func renderFileTree(t Theme, files []*image.FileNode, cursor, offset int, width, height int, focused bool, filterActive bool, filterQuery string, treeMode bool, aggregated bool, collapsed map[string]bool, currentLayer int) string { +func renderFileTree(t Theme, st themeStyles, files []*image.FileNode, cursor, offset int, width, height int, focused bool, filterActive bool, filterQuery string, treeMode bool, aggregated bool, collapsed map[string]bool, currentLayer int) string { contentWidth := width - 2 body, hasAbove, hasBelow := renderTreeBody(treePaneInput{ theme: t, + styles: st, files: files, cursor: cursor, offset: offset, @@ -46,6 +47,7 @@ func renderFileTree(t Theme, files []*image.FileNode, cursor, offset int, width, type treePaneInput struct { theme Theme + styles themeStyles files []*image.FileNode cursor int offset int @@ -83,7 +85,7 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) { var sb strings.Builder if in.showHeader { - sb.WriteString(renderTreeHeader(in.theme, in.contentWidth)) + sb.WriteString(renderTreeHeader(in.styles, in.contentWidth)) sb.WriteString("\n") sb.WriteString(renderDivider(in.theme, in.contentWidth)) sb.WriteString("\n") @@ -108,8 +110,8 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) { midpoint := contentHeight / 2 for i := 0; i < contentHeight; i++ { if i == midpoint { - sb.WriteString(pad) - sb.WriteString(styleWithFg(in.theme.Unchanged).Render(msg)) + sb.WriteString(pad) + sb.WriteString(in.styles.unchanged.Render(msg)) } if i < contentHeight-1 { sb.WriteString("\n") @@ -123,7 +125,7 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) { visible := in.files[in.offset:end] for i, f := range visible { - line := formatFileNodeLine(in.theme, f, in.offset+i == in.cursor, in.contentWidth, in.treeMode, in.collapsed, in.currentLayer, in.filterQuery) + line := formatFileNodeLine(in.theme, in.styles, f, in.offset+i == in.cursor, in.contentWidth, in.treeMode, in.collapsed, in.currentLayer, in.filterQuery) sb.WriteString(line) if i < len(visible)-1 { sb.WriteString("\n") @@ -138,7 +140,7 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) { if in.showFilterBar { sb.WriteString("\n") - sb.WriteString(renderFilterBar(in.theme, in.filterActive, in.filterQuery, len(in.files), in.contentWidth)) + sb.WriteString(renderFilterBar(in.styles, in.filterActive, in.filterQuery, len(in.files), in.contentWidth)) } hasAbove = in.offset > 0 @@ -155,6 +157,7 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) { type splitTreeInput struct { theme Theme + styles themeStyles width, height int currentLayer int treeMode bool @@ -203,6 +206,7 @@ func renderSplitFileTree(in splitTreeInput) string { topBody, topAbove, topBelow := renderTreeBody(treePaneInput{ theme: in.theme, + styles: in.styles, files: in.topFiles, cursor: in.topCursor, offset: in.topOffset, @@ -235,7 +239,7 @@ func renderSplitFileTree(in splitTreeInput) string { emptyMsg: "(no entries at this layer)", }) - divider := renderSplitDivider(in.theme, in.botFocused, contentWidth, in.botFiles, in.botCursor) + divider := renderSplitDivider(in.styles, in.botFocused, contentWidth, in.botFiles, in.botCursor) body := topBody + "\n" + divider + "\n" + botBody @@ -252,7 +256,7 @@ func renderSplitFileTree(in splitTreeInput) string { // match-count and a focus-weight background when that pane has focus. // This places the "▾ Cumulative" affordance on a row that would otherwise // be wasted whitespace. -func renderSplitDivider(t Theme, botFocused bool, contentWidth int, botFiles []*image.FileNode, botCursor int) string { +func renderSplitDivider(st themeStyles, botFocused bool, contentWidth int, botFiles []*image.FileNode, botCursor int) string { label := " ▾ Cumulative " if botFocused && len(botFiles) > 0 { label = fmt.Sprintf(" ▾ Cumulative %d/%d ", botCursor+1, len(botFiles)) @@ -260,11 +264,13 @@ func renderSplitDivider(t Theme, botFocused bool, contentWidth int, botFiles []* label = fmt.Sprintf(" ▾ Cumulative · %d items ", len(botFiles)) } - labelStyle := lipgloss.NewStyle().Foreground(t.Unchanged) - lineStyle := lipgloss.NewStyle().Foreground(t.Separator) + var labelStyle, lineStyle lipgloss.Style if botFocused { - labelStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) - lineStyle = lipgloss.NewStyle().Foreground(t.Accent) + labelStyle = st.accent.Bold(true) + lineStyle = st.accent + } else { + labelStyle = st.unchanged + lineStyle = st.separator } rendered := labelStyle.Render(label) @@ -299,7 +305,7 @@ func buildSplitTitle(in splitTreeInput) string { return topPart + " · " + botPart } -func renderTreeHeader(t Theme, maxWidth int) string { +func renderTreeHeader(st themeStyles, maxWidth int) string { const permCol = 10 const uidGidCol = 8 const sizeCol = 8 @@ -328,19 +334,19 @@ func renderTreeHeader(t Theme, maxWidth int) string { if lipgloss.Width(header) > maxWidth { header = ansi.Truncate(header, maxWidth, "") } - return styleWithFg(t.MetaDim).Render(header) + return st.metaDim.Render(header) } -func renderFilterBar(t Theme, active bool, query string, matchCount int, maxWidth int) string { +func renderFilterBar(st themeStyles, active bool, query string, matchCount int, maxWidth int) string { if active { - prefix := styleWithFg(t.Accent).Render("/ ") + prefix := st.accent.Render("/ ") cursor := query + "█" return prefix + cursor } - prefix := styleWithFg(t.Accent).Render("/ ") - queryStr := styleWithFg(t.Selected).Render(query) - matches := styleWithFg(t.StatusDim).Render(fmt.Sprintf(" (%d matches)", matchCount)) - hint := styleWithFg(t.Unchanged).Render(" [⌫ clear]") + prefix := st.accent.Render("/ ") + queryStr := st.selected.Render(query) + matches := st.statusDimRaw.Render(fmt.Sprintf(" (%d matches)", matchCount)) + hint := st.unchanged.Render(" [⌫ clear]") line := prefix + queryStr + matches + hint lineWidth := lipgloss.Width(line) @@ -350,7 +356,7 @@ func renderFilterBar(t Theme, active bool, query string, matchCount int, maxWidt return line } -func formatFileNodeLine(t Theme, f *image.FileNode, selected bool, maxWidth int, treeMode bool, collapsed map[string]bool, currentLayer int, filterQuery string) string { +func formatFileNodeLine(t Theme, st themeStyles, f *image.FileNode, selected bool, maxWidth int, treeMode bool, collapsed map[string]bool, currentLayer int, filterQuery string) string { perms := image.FormatMode(f.Mode) uidGid := fmt.Sprintf("%d:%d", f.UID, f.GID) flat := !treeMode @@ -428,11 +434,11 @@ func formatFileNodeLine(t Theme, f *image.FileNode, selected bool, maxWidth int, var diffGlyph string switch f.DiffType { case image.Added: - diffGlyph = styleWithFg(t.Added).Render("+ ") + diffGlyph = st.added.Render("+ ") case image.Modified: - diffGlyph = styleWithFg(t.Modified).Render("~ ") + diffGlyph = st.modified.Render("~ ") case image.Removed: - diffGlyph = styleWithFg(t.Removed).Render("- ") + diffGlyph = st.removed.Render("- ") default: diffGlyph = " " } @@ -460,18 +466,18 @@ func formatFileNodeLine(t Theme, f *image.FileNode, selected bool, maxWidth int, metaCols = sizeStr + strings.Repeat(" ", colGap) } fullLine := selGlyph + metaCols + fullName + originSuffix + strings.Repeat(" ", namePad) - return lipgloss.NewStyle().Foreground(t.Selected).Background(t.SelectedBg).Render(fullLine) + return st.selectedTreeBg.Render(fullLine) } var metaCols string if showPerms { - permStr := styleWithFg(t.MetaDim).Render(padRight(perms, permCol)) - uidStr := styleWithFg(t.MetaDim).Render(padRight(uidGid, uidGidCol)) - sizeStr := styleWithFg(t.HeaderDim).Render(padLeft(size, sizeCol)) + permStr := st.metaDim.Render(padRight(perms, permCol)) + uidStr := st.metaDim.Render(padRight(uidGid, uidGidCol)) + sizeStr := st.headerDim.Render(padLeft(size, sizeCol)) gap := strings.Repeat(" ", colGap) metaCols = permStr + gap + uidStr + gap + sizeStr + gap } else if showSize { - sizeStr := styleWithFg(t.HeaderDim).Render(padLeft(size, sizeCol)) + sizeStr := st.headerDim.Render(padLeft(size, sizeCol)) metaCols = sizeStr + strings.Repeat(" ", colGap) } @@ -480,21 +486,21 @@ func formatFileNodeLine(t Theme, f *image.FileNode, selected bool, maxWidth int, fullNameRuneLen := len([]rune(fullName)) if flat || (wasTruncated && prefixRuneLen >= fullNameRuneLen) { - nameRendered = renderNameWithHighlight(t, fullName, filterQuery, diffColorForNode(t, f)) + nameRendered = renderNameWithHighlight(st, fullName, filterQuery, diffColorForNode(t, f)) } else { fullRunes := []rune(fullName) var nameOnly string if prefixRuneLen < len(fullRunes) { nameOnly = string(fullRunes[prefixRuneLen:]) } - treePrefixRendered := styleWithFg(t.TreeDim).Render(treePrefix) - nameOnlyRendered := renderNameWithHighlight(t, nameOnly, filterQuery, diffColorForNode(t, f)) + treePrefixRendered := st.treeDim.Render(treePrefix) + nameOnlyRendered := renderNameWithHighlight(st, nameOnly, filterQuery, diffColorForNode(t, f)) nameRendered = treePrefixRendered + nameOnlyRendered } var originRendered string if showOrigin { - originRendered = styleWithFg(t.MetaDim).Render(originSuffix) + originRendered = st.metaDim.Render(originSuffix) } nameRenderedWidth := lipgloss.Width(nameRendered) + lipgloss.Width(originRendered) @@ -627,7 +633,7 @@ func diffColorForNode(t Theme, f *image.FileNode) color.Color { } } -func renderNameWithHighlight(t Theme, name, query string, fg color.Color) string { +func renderNameWithHighlight(st themeStyles, name, query string, fg color.Color) string { if query == "" || name == "" { return styleWithFg(fg).Render(name) } @@ -654,6 +660,6 @@ func renderNameWithHighlight(t Theme, name, query string, fg color.Color) string after := string(runes[runeIdx+len(queryRunes):]) normal := styleWithFg(fg) - highlight := lipgloss.NewStyle().Foreground(fg).Background(t.SearchHighlightBg) + highlight := st.searchHighlight.Foreground(fg) return normal.Render(before) + highlight.Render(match) + normal.Render(after) } diff --git a/tui/filetree_test.go b/tui/filetree_test.go index 530061f..317b7ec 100644 --- a/tui/filetree_test.go +++ b/tui/filetree_test.go @@ -27,7 +27,7 @@ func TestRenderFileTree_FilterActiveSuppressesBelowIndicator(t *testing.T) { // (the bar persists until the query is cleared). The filter bar // occupies the panel's last row, where renderPanel would otherwise // paint the ▾ scroll indicator. - out := renderFileTree(CatppuccinMocha(), files, 0, 0, 60, 10, true, false, "f", false, false, nil, 0) + out := renderFileTree(CatppuccinMocha(), themeStyles{}, files, 0, 0, 60, 10, true, false, "f", false, false, nil, 0) if strings.Contains(out, "▾") { t.Fatalf("expected no ▾ when filter bar occupies last row; got panel:\n%s", out) } @@ -35,7 +35,7 @@ func TestRenderFileTree_FilterActiveSuppressesBelowIndicator(t *testing.T) { func TestRenderFileTree_NoFilterRetainsBelowIndicator(t *testing.T) { files := fakeFiles(50) - out := renderFileTree(CatppuccinMocha(), files, 0, 0, 60, 10, true, false, "", false, false, nil, 0) + out := renderFileTree(CatppuccinMocha(), themeStyles{}, files, 0, 0, 60, 10, true, false, "", false, false, nil, 0) if !strings.Contains(out, "▾") { t.Fatalf("expected ▾ when overflow exists and filter is not active; got panel:\n%s", out) } @@ -43,7 +43,7 @@ func TestRenderFileTree_NoFilterRetainsBelowIndicator(t *testing.T) { func TestRenderFileTree_TitleSingleLayerMode(t *testing.T) { files := fakeFiles(3) - out := renderFileTree(CatppuccinMocha(), files, 0, 0, 60, 10, true, false, "", false, false, nil, 0) + out := renderFileTree(CatppuccinMocha(), themeStyles{}, files, 0, 0, 60, 10, true, false, "", false, false, nil, 0) if !strings.Contains(out, "Current Layer Contents") { t.Fatalf("expected title to contain 'Current Layer Contents' when aggregated=false; got:\n%s", out) } @@ -54,7 +54,7 @@ func TestRenderFileTree_TitleSingleLayerMode(t *testing.T) { func TestRenderFileTree_TitleAggregatedMode(t *testing.T) { files := fakeFiles(3) - out := renderFileTree(CatppuccinMocha(), files, 0, 0, 60, 10, true, false, "", false, true, nil, 0) + out := renderFileTree(CatppuccinMocha(), themeStyles{}, files, 0, 0, 60, 10, true, false, "", false, true, nil, 0) if !strings.Contains(out, "Aggregated Layer Contents") { t.Fatalf("expected title to contain 'Aggregated Layer Contents' when aggregated=true; got:\n%s", out) } @@ -107,7 +107,7 @@ func TestRenderSplitFileTree_DividerCarriesCumulativeLabel(t *testing.T) { // label — that title is reserved for the split layout. func TestRenderFileTree_SinglePaneOmitsSplitTitle(t *testing.T) { files := fakeFiles(3) - out := renderFileTree(CatppuccinMocha(), files, 0, 0, 60, 10, true, false, "", false, false, nil, 0) + out := renderFileTree(CatppuccinMocha(), themeStyles{}, files, 0, 0, 60, 10, true, false, "", false, false, nil, 0) if strings.Contains(out, "Layer Δ") { t.Fatalf("single-pane file tree must not advertise the split title 'Layer Δ'; got:\n%s", out) } diff --git a/tui/fileview.go b/tui/fileview.go index 3657275..6b3df83 100644 --- a/tui/fileview.go +++ b/tui/fileview.go @@ -36,6 +36,7 @@ type viewerParams struct { // no search is active, it is used directly to skip per-frame chroma work. highlightedLines []string theme Theme + styles themeStyles } func renderFileView(p viewerParams) string { @@ -66,7 +67,7 @@ func renderFileView(p viewerParams) string { msg := fmt.Sprintf("Binary file (%s) — cannot display", image.FormatBytes(p.content.Size)) hint := "Press Esc to return" body := lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Center, - styleWithFg(p.theme.Removed).Render(msg)+"\n\n"+styleWithFg(p.theme.StatusDim).Render(hint)) + p.styles.removed.Render(msg)+"\n\n"+p.styles.statusDimRaw.Render(hint)) return renderPanel(p.theme, body, title, true, contentWidth, p.height, false, false) } @@ -74,7 +75,7 @@ func renderFileView(p viewerParams) string { msg := "Empty file (0 bytes)" hint := "Press Esc to return" body := lipgloss.Place(contentWidth, contentHeight, lipgloss.Center, lipgloss.Center, - styleWithFg(p.theme.Unchanged).Render(msg)+"\n\n"+styleWithFg(p.theme.StatusDim).Render(hint)) + p.styles.unchanged.Render(msg)+"\n\n"+p.styles.statusDimRaw.Render(hint)) return renderPanel(p.theme, body, title, true, contentWidth, p.height, false, false) } @@ -116,11 +117,11 @@ func renderFileView(p viewerParams) string { for i, line := range visible { lineNum := p.offset + i + 1 lineIdx := p.offset + i - gutter := styleWithFg(p.theme.MetaDim).Render(fmt.Sprintf("%*d ", gutterDigits, lineNum)) + gutter := p.styles.metaDim.Render(fmt.Sprintf("%*d ", gutterDigits, lineNum)) gutterW := ansi.StringWidth(gutter) maxLineWidth := max(contentWidth-gutterW, 1) - lineContent := renderViewerLine(p.theme, line, lineIdx, p.searchQuery, p.searchMatches, p.searchCursor, syntaxHighlight) + lineContent := renderViewerLine(p.styles, line, lineIdx, p.searchQuery, p.searchMatches, p.searchCursor, syntaxHighlight) // Cursor sits on the first visible line. Overlay it before truncation // so the cursor cell is preserved (or correctly clipped) by the same @@ -128,7 +129,7 @@ func renderFileView(p viewerParams) string { // positions still adjust hOffset in scrollViewLeft/Right, so by the // time we render the cursor is always within the visible window. if i == 0 { - lineContent = overlayCursor(p.theme, lineContent, p.cursorCol) + lineContent = overlayCursor(p.styles, lineContent, p.cursorCol) } // Horizontal scroll: keep the styled output intact, then trim from @@ -162,13 +163,13 @@ func renderFileView(p viewerParams) string { if showSearchBar { sb.WriteString("\n") - sb.WriteString(renderViewerSearchBar(p.theme, p.searchQuery, p.searchActive, len(p.searchMatches), p.searchCursor, contentWidth)) + sb.WriteString(renderViewerSearchBar(p, p.searchQuery, p.searchActive, len(p.searchMatches), p.searchCursor, contentWidth)) } if p.content.Truncated { notice := fmt.Sprintf(" File truncated at 1 MB (total: %s)", image.FormatBytes(p.content.Size)) sb.WriteString("\n") - sb.WriteString(styleWithFg(p.theme.Modified).Render(notice)) + sb.WriteString(p.styles.modified.Render(notice)) } hasAbove := p.offset > 0 @@ -183,11 +184,11 @@ func renderFileView(p viewerParams) string { // When the cursor is past the end of the rendered line (cursor on a short // line below a longer one), the line is padded with spaces so the block // still renders at the requested column rather than collapsing onto EOL. -func overlayCursor(t Theme, line string, col int) string { +func overlayCursor(st themeStyles, line string, col int) string { if col < 0 { return line } - cursorStyle := lipgloss.NewStyle().Foreground(t.SearchCurrentFg).Background(t.Accent) + cursorStyle := st.cursorOverlay w := ansi.StringWidth(line) if col >= w { pad := strings.Repeat(" ", col-w) @@ -202,12 +203,12 @@ func overlayCursor(t Theme, line string, col int) string { return pre + cursorStyle.Render(cell) + post } -func renderViewerLine(t Theme, line string, lineIdx int, query string, matches [][2]int, matchCursor int, syntaxHighlight bool) string { +func renderViewerLine(st themeStyles, line string, lineIdx int, query string, matches [][2]int, matchCursor int, syntaxHighlight bool) string { if query == "" || len(matches) == 0 { if syntaxHighlight { return line } - return styleWithFg(t.FileName).Render(line) + return st.fileName.Render(line) } lineRunes := []rune(line) @@ -269,44 +270,44 @@ func renderViewerLine(t Theme, line string, lineIdx int, query string, matches [ segments = append(segments, segment{text: string(lineRunes[pos:])}) } if len(segments) == 0 { - return styleWithFg(t.FileName).Render(line) + return st.fileName.Render(line) } var sb strings.Builder for _, seg := range segments { if seg.current { - sb.WriteString(lipgloss.NewStyle().Foreground(t.SearchCurrentFg).Background(t.SearchCurrentBg).Render(seg.text)) + sb.WriteString(st.searchCurrentLine.Render(seg.text)) } else if seg.match { - sb.WriteString(lipgloss.NewStyle().Foreground(t.FileName).Background(t.SearchHighlightBg).Render(seg.text)) + sb.WriteString(st.searchHighlightFile.Render(seg.text)) } else { - sb.WriteString(styleWithFg(t.FileName).Render(seg.text)) + sb.WriteString(st.fileName.Render(seg.text)) } } return sb.String() } -func renderViewerSearchBar(t Theme, query string, active bool, matchCount, cursor, maxWidth int) string { - prefix := styleWithFg(t.Accent).Render("/ ") +func renderViewerSearchBar(p viewerParams, query string, active bool, matchCount, cursor, maxWidth int) string { + prefix := p.styles.accent.Render("/ ") if active { - cursorChar := styleWithFg(t.Selected).Render("█") - queryStr := styleWithFg(t.Selected).Render(query) + cursorChar := p.styles.selected.Render("█") + queryStr := p.styles.selected.Render(query) line := prefix + queryStr + cursorChar if matchCount > 0 { - counter := styleWithFg(t.StatusDim).Render(fmt.Sprintf(" (%d/%d)", cursor+1, matchCount)) + counter := p.styles.statusDimRaw.Render(fmt.Sprintf(" (%d/%d)", cursor+1, matchCount)) line += counter } else if query != "" { - line += styleWithFg(t.StatusDim).Render(" (no matches)") + line += p.styles.statusDimRaw.Render(" (no matches)") } return line } - queryStr := styleWithFg(t.Selected).Render(query) + queryStr := p.styles.selected.Render(query) var counter string if matchCount > 0 { - counter = styleWithFg(t.StatusDim).Render(fmt.Sprintf(" (%d/%d)", cursor+1, matchCount)) + counter = p.styles.statusDimRaw.Render(fmt.Sprintf(" (%d/%d)", cursor+1, matchCount)) } else { - counter = styleWithFg(t.StatusDim).Render(" (no matches)") + counter = p.styles.statusDimRaw.Render(" (no matches)") } - hint := styleWithFg(t.Unchanged).Render(" [Esc clear]") + hint := p.styles.unchanged.Render(" [Esc clear]") line := prefix + queryStr + counter + hint if lipgloss.Width(line) > maxWidth { line = prefix + queryStr + counter diff --git a/tui/layers.go b/tui/layers.go index 5c55284..8097d22 100644 --- a/tui/layers.go +++ b/tui/layers.go @@ -11,7 +11,7 @@ import ( "github.com/deveshctl/layerx/image" ) -func renderLayers(t Theme, layers []image.Layer, cursor int, offset int, width, height int, focused bool, mode sizeColMode, finalLiveSize int64) string { +func renderLayers(t Theme, st themeStyles, layers []image.Layer, cursor int, offset int, width, height int, focused bool, mode sizeColMode, finalLiveSize int64) string { contentWidth := width - 2 listHeight := height @@ -32,7 +32,7 @@ func renderLayers(t Theme, layers []image.Layer, cursor int, offset int, width, var sb strings.Builder for i, layer := range visible { - line := formatLayerLine(t, layer, offset+i == cursor, contentWidth, effMode, finalLiveSize) + line := formatLayerLine(t, st, layer, offset+i == cursor, contentWidth, effMode, finalLiveSize) sb.WriteString(line) if i < len(visible)-1 { sb.WriteString("\n") @@ -61,7 +61,7 @@ func renderLayers(t Theme, layers []image.Layer, cursor int, offset int, width, return renderPanel(t, content, title, focused, contentWidth, height, hasAbove, hasBelow) } -func renderCommandBar(t Theme, cmd string, width int) string { +func renderCommandBar(st themeStyles, cmd string, width int) string { maxLines := 3 wrappedLines := wrapCommandLines(cmd, width-2, maxLines) @@ -76,16 +76,16 @@ func renderCommandBar(t Theme, cmd string, width int) string { } } - prefix := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Render("▶ ") + prefix := st.accent.Bold(true).Render("▶ ") var sb strings.Builder for i := range filled { wl := wrappedLines[i] if i == 0 { - styled := highlightInstruction(t, wl) + styled := highlightInstruction(st, wl) sb.WriteString(prefix + styled) } else { - sb.WriteString(" " + styleWithFg(t.Command).Render(wl)) + sb.WriteString(" " + st.command.Render(wl)) } if i < filled-1 { sb.WriteString("\n") @@ -94,16 +94,16 @@ func renderCommandBar(t Theme, cmd string, width int) string { return sb.String() } -func highlightInstruction(t Theme, line string) string { +func highlightInstruction(st themeStyles, line string) string { parts := strings.SplitN(line, " ", 2) if len(parts) == 0 { return "" } - instruction := lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Render(parts[0]) + instruction := st.accent.Bold(true).Render(parts[0]) if len(parts) == 1 { return instruction } - return instruction + " " + styleWithFg(t.Command).Render(parts[1]) + return instruction + " " + st.command.Render(parts[1]) } func wrapCommandLines(cmd string, width int, maxLines int) []string { @@ -183,7 +183,7 @@ func deltaColor(t Theme, delta int64, finalLiveSize int64) color.Color { return t.HeaderDim } -func formatLayerLine(t Theme, l image.Layer, selected bool, maxWidth int, mode sizeColMode, finalLiveSize int64) string { +func formatLayerLine(t Theme, st themeStyles, l image.Layer, selected bool, maxWidth int, mode sizeColMode, finalLiveSize int64) string { index := fmt.Sprintf("#%d", l.Index) indexWidth := max(len([]rune(index)), 3) @@ -210,26 +210,26 @@ func formatLayerLine(t Theme, l image.Layer, selected bool, maxWidth int, mode s } if selected { - cursor := lipgloss.NewStyle().Foreground(t.Accent).Render("▸") + cursor := st.accent.Render("▸") plain := " " + index + indexPad + " " + sizeText + " " + cmd if lipgloss.Width(plain) > maxWidth-1 { plain = ansi.Truncate(plain, maxWidth-1, "") } - inner := cursor + lipgloss.NewStyle().Foreground(t.Selected).Background(t.SelectedBg).Render(plain) + inner := cursor + st.selectedTreeBg.Render(plain) return inner } - dimHash := styleWithFg(t.MetaDim).Render("#") + dimHash := st.metaDim.Render("#") numStr := fmt.Sprintf("%d", l.Index) numPad := "" if len([]rune(numStr))+1 < indexWidth { numPad = strings.Repeat(" ", indexWidth-len([]rune(numStr))-1) } - sizeRendered := renderSizeColumn(t, l, mode, finalLiveSize) - cmdRendered := styleWithFg(t.Command).Render(cmd) + sizeRendered := renderSizeColumn(t, st, l, mode, finalLiveSize) + cmdRendered := st.command.Render(cmd) - plain := " " + dimHash + styleWithFg(t.FileName).Render(numStr) + numPad + " " + sizeRendered + " " + cmdRendered + plain := " " + dimHash + st.fileName.Render(numStr) + numPad + " " + sizeRendered + " " + cmdRendered lineWidth := lipgloss.Width(plain) if lineWidth > maxWidth { @@ -241,18 +241,18 @@ func formatLayerLine(t Theme, l image.Layer, selected bool, maxWidth int, mode s // renderSizeColumn produces the colored size column for the unselected // row state. Selected rows use the inverted background and skip per-cell // coloring to keep the highlight uniform. -func renderSizeColumn(t Theme, l image.Layer, mode sizeColMode, finalLiveSize int64) string { +func renderSizeColumn(t Theme, st themeStyles, l image.Layer, mode sizeColMode, finalLiveSize int64) string { switch mode { case sizeColBlob: blob := image.FormatBytes(l.Size) w := max(len([]rune(blob)), 7) - return styleWithFg(t.HeaderDim).Render(padLeftRunes(blob, w)) + return st.headerDim.Render(padLeftRunes(blob, w)) case sizeColBoth: blob := image.FormatBytes(l.Size) delta := image.FormatSignedBytes(l.NetDelta) bw := max(len([]rune(blob)), 7) dw := max(len([]rune(delta)), 7) - blobR := styleWithFg(t.HeaderDim).Render(padLeftRunes(blob, bw)) + blobR := st.headerDim.Render(padLeftRunes(blob, bw)) deltaR := styleWithFg(deltaColor(t, l.NetDelta, finalLiveSize)).Render(padLeftRunes(delta, dw)) return blobR + " " + deltaR default: diff --git a/tui/layers_test.go b/tui/layers_test.go index 806c63a..f617223 100644 --- a/tui/layers_test.go +++ b/tui/layers_test.go @@ -22,7 +22,7 @@ func TestFormatLayerLine_DeltaMode_HasSignedColumn(t *testing.T) { // Use exact binary-MB multiples so FormatBytes emits clean values. const mb = 1024 * 1024 l := makeLayer(1, 5*mb, 3*mb, "RUN apt-get install foo") - out := formatLayerLine(CatppuccinMocha(), l, false, 60, sizeColDelta, 30*mb) + out := formatLayerLine(CatppuccinMocha(), themeStyles{}, l, false, 60, sizeColDelta, 30*mb) // Should contain the signed delta string somewhere. assert.Contains(t, out, "+3.0 MB") // Should NOT contain the blob value when in delta mode. @@ -32,7 +32,7 @@ func TestFormatLayerLine_DeltaMode_HasSignedColumn(t *testing.T) { func TestFormatLayerLine_BlobMode_RegressionGuard(t *testing.T) { const mb = 1024 * 1024 l := makeLayer(1, 5*mb, 3*mb, "RUN apt-get install foo") - out := formatLayerLine(CatppuccinMocha(), l, false, 60, sizeColBlob, 30*mb) + out := formatLayerLine(CatppuccinMocha(), themeStyles{}, l, false, 60, sizeColBlob, 30*mb) // Blob value should appear; signed delta should NOT. assert.Contains(t, out, "5.0 MB") assert.NotContains(t, out, "+3.0 MB") @@ -41,7 +41,7 @@ func TestFormatLayerLine_BlobMode_RegressionGuard(t *testing.T) { func TestFormatLayerLine_BothMode_HasBothValues(t *testing.T) { const mb = 1024 * 1024 l := makeLayer(1, 5*mb, 3*mb, "RUN apt-get install foo") - out := formatLayerLine(CatppuccinMocha(), l, false, 80, sizeColBoth, 30*mb) + out := formatLayerLine(CatppuccinMocha(), themeStyles{}, l, false, 80, sizeColBoth, 30*mb) assert.Contains(t, out, "5.0 MB") assert.Contains(t, out, "+3.0 MB") } @@ -49,7 +49,7 @@ func TestFormatLayerLine_BothMode_HasBothValues(t *testing.T) { func TestFormatLayerLine_NegativeDeltaRendersWithMinus(t *testing.T) { const mb = 1024 * 1024 l := makeLayer(2, 1*mb, -2*mb, "RUN apt-get clean") - out := formatLayerLine(CatppuccinMocha(), l, false, 60, sizeColDelta, 30*mb) + out := formatLayerLine(CatppuccinMocha(), themeStyles{}, l, false, 60, sizeColDelta, 30*mb) assert.Contains(t, out, "-2.0 MB") } @@ -60,7 +60,7 @@ func TestRenderLayers_BothMode_FallsBackToDelta_OnNarrowPanel(t *testing.T) { makeLayer(1, 200_000, -2*mb, "RUN clean"), } // width = 36 → contentWidth = 34, below the < 38 fallback threshold. - out := renderLayers(CatppuccinMocha(), layers, 0, 0, 36, 10, true, sizeColBoth, 8*mb) + out := renderLayers(CatppuccinMocha(), themeStyles{}, layers, 0, 0, 36, 10, true, sizeColBoth, 8*mb) // In delta-only fallback the blob value (5.0 MB) must be hidden; // only the signed delta column is rendered. assert.NotContains(t, out, " 5.0 MB", "blob value (unsigned) must not appear in delta-fallback") @@ -69,14 +69,14 @@ func TestRenderLayers_BothMode_FallsBackToDelta_OnNarrowPanel(t *testing.T) { func TestRenderLayers_FocusedTitle_ReflectsSizeMode(t *testing.T) { layers := []image.Layer{makeLayer(0, 1000, 1000, "FROM scratch")} - out := renderLayers(CatppuccinMocha(), layers, 0, 0, 60, 5, true, sizeColDelta, 1000) + out := renderLayers(CatppuccinMocha(), themeStyles{}, layers, 0, 0, 60, 5, true, sizeColDelta, 1000) assert.Contains(t, out, sizeModeLabelChange) - out = renderLayers(CatppuccinMocha(), layers, 0, 0, 60, 5, true, sizeColBlob, 1000) + out = renderLayers(CatppuccinMocha(), themeStyles{}, layers, 0, 0, 60, 5, true, sizeColBlob, 1000) assert.Contains(t, out, sizeModeLabelStored) assert.False(t, strings.Contains(out, sizeModeLabelChange), "stored mode title should not show change label") - out = renderLayers(CatppuccinMocha(), layers, 0, 0, 60, 5, true, sizeColBoth, 1000) + out = renderLayers(CatppuccinMocha(), themeStyles{}, layers, 0, 0, 60, 5, true, sizeColBoth, 1000) assert.Contains(t, out, sizeModeLabelBoth) } diff --git a/tui/model.go b/tui/model.go index 88a734f..5ad0b2f 100644 --- a/tui/model.go +++ b/tui/model.go @@ -297,6 +297,9 @@ type model struct { // treeCache memoizes displayTreeFor across frames. Behind a pointer so the // value-receiver method can write through it and all model copies share it. treeCache *treeCache + // styles holds lipgloss.Style values derived from the session theme. + // Built once in NewModel; reused every frame to avoid per-call allocation. + styles themeStyles fetchCtx context.Context fetchCancel context.CancelFunc @@ -343,7 +346,8 @@ func NewModel(cfg Config) model { noCache: cfg.NoCache, theme: theme, transparentBg: cfg.TransparentBg, - treeCache: &treeCache{}, + treeCache: &treeCache{}, + styles: newThemeStyles(theme), renderedImageRef: renderGradient(cfg.ImageRef, theme.GradientStart, theme.GradientEnd), fetchCtx: ctx, fetchCancel: cancel, @@ -1705,7 +1709,7 @@ func (m model) viewLoading() tea.View { var lines []string lines = append(lines, "") - lines = append(lines, " "+lipgloss.NewStyle().Foreground(m.theme.Accent).Bold(true).Render("◆ layerx")) + lines = append(lines, " "+m.styles.accent.Bold(true).Render("◆ layerx")) lines = append(lines, "") switch m.loadPhase { @@ -1730,8 +1734,8 @@ func (m model) viewLoading() tea.View { } if barWidth >= 4 { filled := barWidth * pct / 100 - bar := lipgloss.NewStyle().Foreground(m.theme.Accent).Render(strings.Repeat("━", filled)) + - lipgloss.NewStyle().Foreground(m.theme.Separator).Render(strings.Repeat("─", barWidth-filled)) + bar := m.styles.accent.Render(strings.Repeat("━", filled)) + + m.styles.separator.Render(strings.Repeat("─", barWidth-filled)) detail += fmt.Sprintf(" [%s]%s", bar, bytesText) } else { detail += bytesText @@ -1764,8 +1768,7 @@ func (m model) viewLoading() tea.View { } lines = append(lines, "") - hintStyle := lipgloss.NewStyle().Foreground(m.theme.StatusDim) - lines = append(lines, " "+hintStyle.Render("Press q or Esc to exit.")) + lines = append(lines, " "+m.styles.statusDimRaw.Render("Press q or Esc to exit.")) lines = append(lines, "") boxWidth := 52 @@ -1798,7 +1801,7 @@ func (m model) viewLoading() tea.View { func (m model) renderRightPanel(width, height int) string { if !m.aggregated { treeFiles := m.displayTreeFor(focusTree) - return renderFileTree(m.theme, treeFiles, m.treeCursor, m.treeOffset, + return renderFileTree(m.theme, m.styles, treeFiles, m.treeCursor, m.treeOffset, width, height, m.focus == focusTree, m.filterActive, m.filterQuery, m.useTreeCollapse(), false, m.treeCollapsed, m.layerCursor) @@ -1807,6 +1810,7 @@ func (m model) renderRightPanel(width, height int) string { botFiles := m.displayTreeFor(focusTreeAgg) return renderSplitFileTree(splitTreeInput{ theme: m.theme, + styles: m.styles, width: width, height: height, currentLayer: m.layerCursor, @@ -1837,8 +1841,7 @@ func (m model) viewError() tea.View { wrapWidth = 1 } errStyle := lipgloss.NewStyle().Foreground(m.theme.Removed).Bold(true).Width(wrapWidth) - hintStyle := lipgloss.NewStyle().Foreground(m.theme.StatusDim) - msg := errStyle.Render("Error: "+m.errMsg) + "\n\n" + hintStyle.Render("Press q or Esc to exit.") + msg := errStyle.Render("Error: "+m.errMsg) + "\n\n" + m.styles.statusDimRaw.Render("Press q or Esc to exit.") content := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, msg) return finalizeView(tea.NewView(content), m.viewBg()) } @@ -1869,7 +1872,7 @@ func (m model) viewReady() tea.View { if m.viewState == viewNone { treeFiles = m.displayTree() } - left := renderLayers(m.theme, m.layers(), m.layerCursor, m.layerOffset, leftWidth, panelHeight, m.focus == focusLayers, m.sizeMode, m.finalLiveSize()) + left := renderLayers(m.theme, m.styles, m.layers(), m.layerCursor, m.layerOffset, leftWidth, panelHeight, m.focus == focusLayers, m.sizeMode, m.finalLiveSize()) right := m.renderRightPanel(rightWidth, panelHeight) panels := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) @@ -1894,6 +1897,7 @@ func (m model) viewReady() tea.View { searchActive: m.viewSearchActive, highlightedLines: m.viewHighlightedLines, theme: m.theme, + styles: m.styles, }) panels = viewer } @@ -1903,9 +1907,9 @@ func (m model) viewReady() tea.View { if m.layerCursor < len(layers) { cmd = layers[m.layerCursor].Command } - commandBar := renderCommandBar(m.theme, cmd, m.width) + commandBar := renderCommandBar(m.styles, cmd, m.width) - sep := lipgloss.NewStyle().Foreground(m.theme.Separator).Render(strings.Repeat("─", m.width)) + sep := m.styles.separator.Render(strings.Repeat("─", m.width)) status := m.renderStatusBar(treeFiles) content := lipgloss.JoinVertical(lipgloss.Left, header, panels, commandBar, sep, status) @@ -1943,36 +1947,34 @@ func (m model) leftPanelWidth() int { } func (m model) renderHeader() string { - glyph := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Render("◆") - brand := lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Bold(true).Render(" layerx") - sep := lipgloss.NewStyle().Foreground(m.theme.HeaderSep).Background(m.theme.StatusBg).Render(" │ ") - imageName := lipgloss.NewStyle().Background(m.theme.StatusBg).Render(m.renderedImageRef) + glyph := m.styles.accentBg.Render("◆") + brand := m.styles.accentBg.Bold(true).Render(" layerx") + sep := m.styles.headerSep.Render(" │ ") + imageName := m.styles.bgOnly.Render(m.renderedImageRef) left := glyph + brand + sep + imageName // Append the active platform after the image name when --platform is // set. Multi-platform images otherwise give no visual cue which variant // is on screen — easy to misread an arm64 layout as amd64. if m.platform != "" { - platformStyle := lipgloss.NewStyle().Foreground(m.theme.HeaderDim).Background(m.theme.StatusBg) - left += sep + platformStyle.Render(m.platform) + left += sep + m.styles.headerDimBg.Render(m.platform) } totalSize := image.FormatBytes(m.analysis.TotalSize) layerCount := fmt.Sprintf("%d layers", len(m.analysis.Layers)) - right := lipgloss.NewStyle().Foreground(m.theme.HeaderDim).Background(m.theme.StatusBg).Render(layerCount + " · " + totalSize) + right := m.styles.headerDimBg.Render(layerCount + " · " + totalSize) gap := max(m.width-lipgloss.Width(left)-lipgloss.Width(right)-1, 1) - bgStyle := lipgloss.NewStyle().Background(m.theme.StatusBg) - return bgStyle.Render(" " + left + strings.Repeat(" ", gap) + right) + return m.styles.bgOnly.Render(" " + left + strings.Repeat(" ", gap) + right) } func (m model) renderStatusBar(treeFiles []*image.FileNode) string { if m.viewState != viewNone { return m.renderViewerStatusBar() } - keyStyle := lipgloss.NewStyle().Foreground(m.theme.StatusKey).Background(m.theme.StatusBg).Bold(true) - descStyle := lipgloss.NewStyle().Foreground(m.theme.StatusDim).Background(m.theme.StatusBg) - sepStyle := lipgloss.NewStyle().Foreground(m.theme.HeaderSep).Background(m.theme.StatusBg) + keyStyle := m.styles.statusKey + descStyle := m.styles.statusDim + sepStyle := m.styles.headerSep type hint struct{ key, desc string } var hints []hint @@ -2032,15 +2034,13 @@ func (m model) renderStatusBar(treeFiles []*image.FileNode) string { layers := m.layers() var right string if m.statusMsg != "" { - fg := m.theme.Added + msgStyle := m.styles.addedBg if strings.HasPrefix(m.statusMsg, "Error:") { - fg = m.theme.Removed + msgStyle = m.styles.removedStatusBg } - msgStyle := lipgloss.NewStyle().Foreground(fg).Background(m.theme.StatusBg).Bold(true) right = msgStyle.Render(m.statusMsg) + " " } else if m.copyConfirm { - copiedStyle := lipgloss.NewStyle().Foreground(m.theme.Added).Background(m.theme.StatusBg).Bold(true) - right = copiedStyle.Render("Copied!") + " " + right = m.styles.addedBg.Render("Copied!") + " " } else { badges := "" if m.efficiency != nil { @@ -2049,19 +2049,19 @@ func (m model) renderStatusBar(treeFiles []*image.FileNode) string { if m.efficiency.WastedBytes > 0 { effStr += " · " + image.FormatBytes(m.efficiency.WastedBytes) + " wasted" } - badges += lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Render("["+effStr+"]") + " " + badges += m.styles.accentBg.Render("["+effStr+"]") + " " } if m.diffOnly { - badges += lipgloss.NewStyle().Foreground(m.theme.Modified).Background(m.theme.StatusBg).Render("[diff]") + " " + badges += m.styles.modifiedBg.Render("[diff]") + " " } if m.aggregated { - badges += lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Render("[split]") + " " + badges += m.styles.accentBg.Render("[split]") + " " } switch m.sortMode { case sortDesc: - badges += lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Render("[↓size]") + " " + badges += m.styles.accentBg.Render("[↓size]") + " " case sortAsc: - badges += lipgloss.NewStyle().Foreground(m.theme.Accent).Background(m.theme.StatusBg).Render("[↑size]") + " " + badges += m.styles.accentBg.Render("[↑size]") + " " } layerNum := fmt.Sprintf("%d", m.layerCursor+1) @@ -2070,7 +2070,7 @@ func (m model) renderStatusBar(treeFiles []*image.FileNode) string { if m.layerCursor < len(layers) { size = image.FormatBytes(layers[m.layerCursor].Size) } - rightHighlight := lipgloss.NewStyle().Foreground(m.theme.Selected).Background(m.theme.StatusBg).Bold(true).Render("Layer " + layerNum) + rightHighlight := m.styles.selectedStatusBg.Render("Layer " + layerNum) sizeLabel := "stored " + size if m.focus == focusLayers && m.layerCursor < len(layers) { switch m.sizeMode { @@ -2080,20 +2080,19 @@ func (m model) renderStatusBar(treeFiles []*image.FileNode) string { sizeLabel = "stored " + size + " · change " + image.FormatSignedBytes(layers[m.layerCursor].NetDelta) } } - rightDim := lipgloss.NewStyle().Foreground(m.theme.StatusDim).Background(m.theme.StatusBg).Render("/" + layerTotal + " · " + sizeLabel) + rightDim := m.styles.statusDim.Render("/" + layerTotal + " · " + sizeLabel) right = badges + rightHighlight + rightDim + " " } gap := max(m.width-lipgloss.Width(hintStr)-lipgloss.Width(right), 0) - bgStyle := lipgloss.NewStyle().Background(m.theme.StatusBg) - return bgStyle.Render(hintStr + strings.Repeat(" ", gap) + right) + return m.styles.bgOnly.Render(hintStr + strings.Repeat(" ", gap) + right) } func (m model) renderViewerStatusBar() string { - keyStyle := lipgloss.NewStyle().Foreground(m.theme.StatusKey).Background(m.theme.StatusBg).Bold(true) - descStyle := lipgloss.NewStyle().Foreground(m.theme.StatusDim).Background(m.theme.StatusBg) - sepStyle := lipgloss.NewStyle().Foreground(m.theme.HeaderSep).Background(m.theme.StatusBg) + keyStyle := m.styles.statusKey + descStyle := m.styles.statusDim + sepStyle := m.styles.headerSep hints := " " + keyStyle.Render("j/k") + " " + descStyle.Render("up/down") + " " + @@ -2112,10 +2111,9 @@ func (m model) renderViewerStatusBar() string { var right string if m.copyConfirm { - copiedStyle := lipgloss.NewStyle().Foreground(m.theme.Added).Background(m.theme.StatusBg).Bold(true) - right = copiedStyle.Render("Copied!") + " " + right = m.styles.addedBg.Render("Copied!") + " " } else if len(m.viewSearchMatches) > 0 { - matchStyle := lipgloss.NewStyle().Foreground(m.theme.SearchCurrentBg).Background(m.theme.StatusBg).Bold(true) + matchStyle := m.styles.searchMatchStyle right = matchStyle.Render(fmt.Sprintf("Match %d/%d ", m.viewSearchCursor+1, len(m.viewSearchMatches))) } else if m.viewContent != nil && !m.viewContent.Binary && len(m.viewContent.Data) > 0 { total := m.viewLineCount() @@ -2124,14 +2122,12 @@ func (m model) renderViewerStatusBar() string { if total > 0 { pct = line * 100 / total } - rightDim := lipgloss.NewStyle().Foreground(m.theme.StatusDim).Background(m.theme.StatusBg) - right = rightDim.Render(fmt.Sprintf("Line %d/%d (%d%%) ", line, total, pct)) + right = m.styles.statusDim.Render(fmt.Sprintf("Line %d/%d (%d%%) ", line, total, pct)) } gap := max(m.width-lipgloss.Width(hints)-lipgloss.Width(right), 0) - bgStyle := lipgloss.NewStyle().Background(m.theme.StatusBg) - return bgStyle.Render(hints + strings.Repeat(" ", gap) + right) + return m.styles.bgOnly.Render(hints + strings.Repeat(" ", gap) + right) } func (m model) fetchFileContent(ctx context.Context, path string, requestID uint64) tea.Cmd { diff --git a/tui/model_test.go b/tui/model_test.go index a84e95c..40e2a24 100644 --- a/tui/model_test.go +++ b/tui/model_test.go @@ -763,13 +763,13 @@ func TestFriendlyErrorGenericReturnsMessage(t *testing.T) { func TestRenderLayersDoesNotPanic(t *testing.T) { a := testAnalysis() assert.NotPanics(t, func() { - renderLayers(CatppuccinMocha(), a.Layers, 0, 0, 40, 20, true, sizeColDelta, 0) + renderLayers(CatppuccinMocha(), themeStyles{}, a.Layers, 0, 0, 40, 20, true, sizeColDelta, 0) }) } func TestRenderLayersEmptyDoesNotPanic(t *testing.T) { assert.NotPanics(t, func() { - renderLayers(CatppuccinMocha(), nil, 0, 0, 40, 20, false, sizeColDelta, 0) + renderLayers(CatppuccinMocha(), themeStyles{}, nil, 0, 0, 40, 20, false, sizeColDelta, 0) }) } @@ -779,12 +779,12 @@ func TestRenderFileTreeDoesNotPanic(t *testing.T) { m := setupModel() files := m.displayTree() assert.NotPanics(t, func() { - renderFileTree(CatppuccinMocha(), files, 0, 0, 60, 20, true, false, "", true, false, nil, 0) + renderFileTree(CatppuccinMocha(), themeStyles{}, files, 0, 0, 60, 20, true, false, "", true, false, nil, 0) }) } func TestRenderFileTreeEmptyShowsPlaceholder(t *testing.T) { - output := renderFileTree(CatppuccinMocha(), nil, 0, 0, 60, 20, false, false, "", true, false, nil, 0) + output := renderFileTree(CatppuccinMocha(), themeStyles{}, nil, 0, 0, 60, 20, false, false, "", true, false, nil, 0) assert.Contains(t, output, "no filesystem changes") } @@ -800,7 +800,7 @@ func TestRenderFileTreeCollapsedDirShowsGlyph(t *testing.T) { } updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) um := updated.(model) - line := renderFileTree(CatppuccinMocha(), um.displayTree(), um.treeCursor, 0, 80, 20, true, false, "", true, false, um.treeCollapsed, 0) + line := renderFileTree(CatppuccinMocha(), themeStyles{}, um.displayTree(), um.treeCursor, 0, 80, 20, true, false, "", true, false, um.treeCollapsed, 0) assert.Contains(t, line, "▸") } diff --git a/tui/styles.go b/tui/styles.go index 6af4df0..f233fe7 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -92,6 +92,99 @@ func styleWithFg(c color.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } +// themeStyles holds lipgloss.Style values derived solely from the session +// Theme. The theme is fixed at NewModel time and never changes, so these +// styles are built once and reused for every frame instead of being +// allocated anew on each styleWithFg / lipgloss.NewStyle() call. +// +// Dynamic styles — those whose color or content depends on per-row state +// (selected background, diff color, delta color, search highlight with a +// per-row fg) — are NOT included here; they remain inline at their call +// sites. +type themeStyles struct { + // file tree + metaDim lipgloss.Style + headerDim lipgloss.Style + treeDim lipgloss.Style + unchanged lipgloss.Style + added lipgloss.Style + modified lipgloss.Style + removed lipgloss.Style + accent lipgloss.Style + fileName lipgloss.Style + // status / header bar chrome (with StatusBg background) + statusKey lipgloss.Style + statusDim lipgloss.Style + headerSep lipgloss.Style + headerDimBg lipgloss.Style + accentBg lipgloss.Style + modifiedBg lipgloss.Style + addedBg lipgloss.Style + bgOnly lipgloss.Style + // misc + command lipgloss.Style + separator lipgloss.Style + selected lipgloss.Style + statusDimRaw lipgloss.Style // StatusDim without StatusBg (loading/error screens) + // searchHighlight is the background used for non-current search matches + // in both the file tree name and the file viewer. + searchHighlight lipgloss.Style + // searchHighlightFile is FileName+SearchHighlightBg, used for non-current + // match segments in the file viewer where the fg is always FileName. + searchHighlightFile lipgloss.Style + // cursorOverlay is the style applied to the character under the file-viewer + // cursor. Both inputs (SearchCurrentFg, Accent) are immutable for the session. + cursorOverlay lipgloss.Style + // selectedStatusBg is Selected+StatusBg+Bold, used for the layer number in + // the status bar right section. + selectedStatusBg lipgloss.Style + // selectedTreeBg is Selected+SelectedBg, used for the selected row in the + // file tree and layer list. + selectedTreeBg lipgloss.Style + // searchMatchStyle is SearchCurrentBg+StatusBg+Bold, used for the match + // counter in the viewer status bar. + searchMatchStyle lipgloss.Style + // searchCurrentLine is SearchCurrentFg+SearchCurrentBg, used for the + // current search match segment in the file viewer. + searchCurrentLine lipgloss.Style + // removedStatusBg is Removed+StatusBg+Bold, used for the error status message. + removedStatusBg lipgloss.Style +} + +func newThemeStyles(t Theme) themeStyles { + return themeStyles{ + metaDim: lipgloss.NewStyle().Foreground(t.MetaDim), + headerDim: lipgloss.NewStyle().Foreground(t.HeaderDim), + treeDim: lipgloss.NewStyle().Foreground(t.TreeDim), + unchanged: lipgloss.NewStyle().Foreground(t.Unchanged), + added: lipgloss.NewStyle().Foreground(t.Added), + modified: lipgloss.NewStyle().Foreground(t.Modified), + removed: lipgloss.NewStyle().Foreground(t.Removed), + accent: lipgloss.NewStyle().Foreground(t.Accent), + fileName: lipgloss.NewStyle().Foreground(t.FileName), + statusKey: lipgloss.NewStyle().Foreground(t.StatusKey).Background(t.StatusBg).Bold(true), + statusDim: lipgloss.NewStyle().Foreground(t.StatusDim).Background(t.StatusBg), + headerSep: lipgloss.NewStyle().Foreground(t.HeaderSep).Background(t.StatusBg), + headerDimBg: lipgloss.NewStyle().Foreground(t.HeaderDim).Background(t.StatusBg), + accentBg: lipgloss.NewStyle().Foreground(t.Accent).Background(t.StatusBg), + modifiedBg: lipgloss.NewStyle().Foreground(t.Modified).Background(t.StatusBg), + addedBg: lipgloss.NewStyle().Foreground(t.Added).Background(t.StatusBg).Bold(true), + bgOnly: lipgloss.NewStyle().Background(t.StatusBg), + command: lipgloss.NewStyle().Foreground(t.Command), + separator: lipgloss.NewStyle().Foreground(t.Separator), + selected: lipgloss.NewStyle().Foreground(t.Selected), + statusDimRaw: lipgloss.NewStyle().Foreground(t.StatusDim), + searchHighlight: lipgloss.NewStyle().Background(t.SearchHighlightBg), + searchHighlightFile: lipgloss.NewStyle().Foreground(t.FileName).Background(t.SearchHighlightBg), + cursorOverlay: lipgloss.NewStyle().Foreground(t.SearchCurrentFg).Background(t.Accent), + selectedStatusBg: lipgloss.NewStyle().Foreground(t.Selected).Background(t.StatusBg).Bold(true), + selectedTreeBg: lipgloss.NewStyle().Foreground(t.Selected).Background(t.SelectedBg), + searchMatchStyle: lipgloss.NewStyle().Foreground(t.SearchCurrentBg).Background(t.StatusBg).Bold(true), + searchCurrentLine: lipgloss.NewStyle().Foreground(t.SearchCurrentFg).Background(t.SearchCurrentBg), + removedStatusBg: lipgloss.NewStyle().Foreground(t.Removed).Background(t.StatusBg).Bold(true), + } +} + // renderGradient interpolates linearly from `from` to `to` across the runes // of text, rendering each rune in its own per-character colour. Single-rune // strings get the start colour; the transition is spread evenly across longer From 08b8a3a17196e84d3e5f1613f64d47adcfd2cc1f Mon Sep 17 00:00:00 2001 From: deveshctl Date: Wed, 5 Aug 2026 15:23:42 +0530 Subject: [PATCH 2/3] style(tui): fix indentation of pad write in empty-tree branch The sb.WriteString(pad) line in renderTreeBody's empty-tree placeholder was indented one tab short of its sibling statement, a whitespace glitch introduced alongside the style-cache change. Pure formatting; no behaviour change. --- tui/filetree.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/filetree.go b/tui/filetree.go index d124586..e22fbca 100644 --- a/tui/filetree.go +++ b/tui/filetree.go @@ -110,7 +110,7 @@ func renderTreeBody(in treePaneInput) (body string, hasAbove, hasBelow bool) { midpoint := contentHeight / 2 for i := 0; i < contentHeight; i++ { if i == midpoint { - sb.WriteString(pad) + sb.WriteString(pad) sb.WriteString(in.styles.unchanged.Render(msg)) } if i < contentHeight-1 { From a98be794a3df22d4a787a31b70b8c8798d4e025d Mon Sep 17 00:00:00 2001 From: deveshctl Date: Wed, 5 Aug 2026 17:27:31 +0530 Subject: [PATCH 3/3] fix(tui): pass styles to bottom pane in split-tree render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The botBody treePaneInput literal in renderSplitFileTree was missing styles: in.styles, so the Cumulative pane in aggregated split mode rendered with a zero-value themeStyles — no colors, no diff highlighting, no selection background, no search styling. topBody was correctly threaded; this brings botBody in line. --- tui/filetree.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tui/filetree.go b/tui/filetree.go index e22fbca..c3e87cd 100644 --- a/tui/filetree.go +++ b/tui/filetree.go @@ -224,6 +224,7 @@ func renderSplitFileTree(in splitTreeInput) string { botBody, botAbove, botBelow := renderTreeBody(treePaneInput{ theme: in.theme, + styles: in.styles, files: in.botFiles, cursor: in.botCursor, offset: in.botOffset,