From a14cb55455436dde7deb1182c7c2d069d5fd0339 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Mon, 22 Jun 2026 01:06:07 -0400 Subject: [PATCH 1/2] Redesign the shared header and embed the GitHub logo as an image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the shared gh-stack header (used by `view` and `modify`, and reused by `submit` later in this stack) for a cleaner, more responsive look, and replace the braille/ASCII Invertocat with a real image of the GitHub mark. - Logo: embed the Invertocat PNG with go:embed and draw it via an inline- image protocol (kitty or iTerm2). It is image-or-nothing: when no protocol is available, stdout is not a TTY, or we are inside tmux/screen, no logo is drawn and the text falls back to the normal left padding. Detection is environment-based and cached so it never blocks the TUI, and a fixed kitty image id lets the header clear or replace the logo in place instead of leaving copies behind. - Layout: place the logo in the top-left corner beside the title and version, with the stack-info lines left-aligned beneath it on the same left margin. Size the box to its content for each view so there is no trailing empty row. - Responsiveness: hide the logo progressively — first when the viewport is too narrow, then a little before the rest of the header at short heights, where a vertical resize could otherwise leave a ghost of the inline image. - Add unit tests for the header's responsive thresholds. --- go.mod | 3 +- go.sum | 4 + internal/tui/modifyview/model.go | 23 +- .../tui/shared/assets/invertocat-white.png | Bin 0 -> 2676 bytes internal/tui/shared/header.go | 199 ++++++++++++------ internal/tui/shared/header_test.go | 41 ++++ internal/tui/shared/logo.go | 172 +++++++++++++++ internal/tui/shared/render.go | 18 +- internal/tui/stackview/model.go | 29 +-- 9 files changed, 396 insertions(+), 93 deletions(-) create mode 100644 internal/tui/shared/assets/invertocat-white.png create mode 100644 internal/tui/shared/header_test.go create mode 100644 internal/tui/shared/logo.go diff --git a/go.mod b/go.mod index 487f0f5..6307665 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.0 require ( github.com/AlecAivazis/survey/v2 v2.3.7 + github.com/BourgeoisBear/rasterm v1.1.2 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 @@ -14,6 +15,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 golang.org/x/sys v0.45.0 + golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 ) @@ -49,6 +51,5 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/thlib/go-timezone-local v0.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/term v0.43.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2a45543..1acb610 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= +github.com/BourgeoisBear/rasterm v1.1.2 h1:hWHZBZ45N366uNSqxWFYBV0y19q8fXRXADhPkoLF4Ss= +github.com/BourgeoisBear/rasterm v1.1.2/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= @@ -135,10 +137,12 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/tui/modifyview/model.go b/internal/tui/modifyview/model.go index 169e81b..debeec3 100644 --- a/internal/tui/modifyview/model.go +++ b/internal/tui/modifyview/model.go @@ -1191,11 +1191,17 @@ func (m Model) nodeLineCount(idx int) int { return shared.NodeLineCount(toNodeData(m.nodes[idx], idx, idx)) } -func (m Model) contentViewHeight() int { - reserved := 3 // post-scroll newline + context line + status bar +// headerHeight returns the number of rows the header occupies for this model's +// config, or 0 when the header is hidden. +func (m Model) headerHeight() int { if shared.ShouldShowHeader(m.width, m.height) { - reserved += shared.HeaderHeight + return shared.HeaderHeightFor(m.buildHeaderConfig()) } + return 0 +} + +func (m Model) contentViewHeight() int { + reserved := 3 + m.headerHeight() // post-scroll newline + context line + status bar h := m.height - reserved if h < 1 { h = 1 @@ -1221,7 +1227,7 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { nodes[i] = toNodeData(n, i, i) } - result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, shared.ShouldShowHeader(m.width, m.height), false) + result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, m.headerHeight(), false) if result.NodeIndex < 0 { return m, nil } @@ -1357,6 +1363,10 @@ func (m Model) View() string { showHeader := shared.ShouldShowHeader(m.width, m.height) if showHeader { shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height) + } else { + // The header (and its inline-image logo) is hidden; clear any logo that + // was previously drawn so it does not linger in the graphics layer. + out.WriteString(shared.ClearLogo()) } // Build the scrollable branch list content @@ -1382,10 +1392,7 @@ func (m Model) View() string { bottomLines := 2 // error/status line + status bar (post-scroll newline is inline) // Scrolling — reserve space for header and fixed bottom - reservedLines := bottomLines - if showHeader { - reservedLines += shared.HeaderHeight - } + reservedLines := bottomLines + m.headerHeight() viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 diff --git a/internal/tui/shared/assets/invertocat-white.png b/internal/tui/shared/assets/invertocat-white.png new file mode 100644 index 0000000000000000000000000000000000000000..6e9c4d96f1fb104e66c47b2b0b7df785854e93bc GIT binary patch literal 2676 zcmV-)3XAoLP)TU#i#5)cptS7fOa z(1?^xD7e8FYh6Gs{m05u47ebah>!}hRD%Ix6KN1oK!LI-X)9Yrq{RX)(DyUPJ0E>M z-}k<|&6&CP&M#>~O46A*XWpHevl@*5$;!^M zDHa2>fqj8taYMLO`Y7Pjz>UDmgvUzYN5G-LTjFLYN9h{@2Ljgws`Or);rqb&xDoOx zeskapz*4HuV&JHFM5I&vR=@?oKdB#20H*^Z<5!{k`F8@avhc_TUbUCRjZ7Vo^9{JCZGn+={1O;#stj-d@NCKjN4mchs(B;;(}CBzEGm+X ze^>6gXwA6syf2Tnz|p1(*yl{7e8lB_iU}2QfdW5tu<*zRTJoAQftqxG);7_ITfS{V z0+{P~!IA&xY!d>&8C@n9agSWGkMXU13DJtTeSe5Q<%7PIFX7hFq2r&`z+T$Q-=(2G z{7_jAY^@oREiv+uu((|%8acl8gJ!mC<$uH)Bg*7{;A$IFe-m)8HGS5NSAahNzXGnX zYnHW_$?L#mSIXZ8c#iVv8zg>UUmHX4XR6CRz}J9%x<&igu(-1MtAy3N*Z@CuFxdxF zj=djnRYkmuYk*7Ax^ZnDW7%9-Sm7O$e4m3)zi@HJdF%@O+|gW$BAfFfU~I-U$fuvw%<8tlm9r+W1bu+w7mQ_V0EBCfU2r z$-p=5d3~3CZ}DdBQy6kf9beWq35CWRN?-QhJ({PMtM zHd+V!xgTM20({t^e5dD~*TCeq1y(tn;FyB*8)g;mC7c@Ari6c^&!K#uksnHt$w|cw zSF~y$2cH69N8EBj-iht(*w()$*TiZDumx^;DVLep*3xmwDkl=H7$kQY*UZg2sj&*y zxYf?2S|LbYA+C`Z%obTDC6%&Vv#vJTF9onitp;Ds;_cIN^`_sIH3gWcB{Rh7RxvMR|WR%?9|L;)ISg4Tr>8q{XdiZ0(wM_VZ1{AxQ$&OfK1>*JPy9rY85A z21DZZc4=wt#yPzvChiU{0^a zM~tRv_|XC7k5;^VOo>YFjiY3d!OX&&$$JZ}{H_+92(uvG!~srdsecU7jW8-MXF*s6e@P9BlL{-`uqORb=B^{s#{ zC7s+S1Kx@rC;1j|=m4iG>f>f(OP*AR7i?dYIa~b z*GF2?Jl}#274+Ng{Wl^=%BN}~zK6H_7ItU=+u6?HnoN;_M|o)c^aI}l4-MQ@WADZK zH>-#OOO*$IJ#TU4Izm$JR{KY|w0!~#&}T_36>CKHn~IUlgZ!^)-vH;}hkSKxYXnI- zdLL{k6$4E5Gzt0a9c~&5a1#DFptDwucCn%Xq%Oh!reOd_*t?Lgwl*VumMiPz*|a@& z>Hu)4Re~>W>Ncqv_~l+YepNw1SYdoS9|K;l3F+*d(=F z!@Uq!==bXMLowUi9&e_D_2Qc=O%+VV zAnAO12Vv2>Rtw^ULIODy66Pk3Qc+q?_!%U z&L5)jVir&0{BHe*eFAK0jXKi!I%JMdTfsVOr0}|-Ztvm(wr0<=ei;wh&Ylx|_}X}l~VCC#lL@)ql?n)BFgTCEvTjK zw=aW>@+@|Ywan$AWm~BaD|wR*MStdnW<o7Bc-Qnm;e9QWY|i<3O{t zzHZmhF3~D03L0|uN%4dlXvLnF3ymVc_R8h;^CJBpw=BBY&lH@*%_+thU%*cA9peEK ze?{obEMuX=3RV)6`@kL!eB^Q&JInZW@T?_VtDclLl2mwz;gEqi=DI#gAfPEdP^df4=23S+Mq*-7qjnt?H zY=EbMiKdF!Bon8_4{N>w?yg026##`rA*VXhNB`m0dEAbhN%VSFXr<((kRk2 z6}jjDrvS@x2yj^d^B= MinWidthForShortcuts } +// artFitsViewport reports whether the viewport is wide and tall enough to show +// the logo. The height bound (MinHeightForArt) is a little above +// MinHeightForHeader so the logo is dropped before the header itself at short +// heights, where a vertical resize can otherwise leave a transient ghost of the +// inline image. +func artFitsViewport(width, height int) bool { + return width >= MinWidthForArt && height >= MinHeightForArt +} + +// shortcutRowCount returns how many rows the shortcut block occupies for the +// config's column count. +func shortcutRowCount(cfg HeaderConfig) int { + n := len(cfg.Shortcuts) + if n == 0 { + return 0 + } + cols := cfg.ShortcutColumns + if cols < 1 { + cols = 1 + } + return (n + cols - 1) / cols +} + +// headerContentRows returns how many content rows the header needs: enough for +// the title/subtitle/info block or the shortcut block, whichever is taller. This +// keeps the box exactly as tall as its content, with no trailing empty row. +func headerContentRows(cfg HeaderConfig) int { + // title (row 0), subtitle (row 1), a gap (row 2), then the info lines. + info := 3 + len(cfg.InfoLines) + sc := shortcutRowCount(cfg) + if sc > info { + return sc + } + return info +} + +// HeaderHeightFor returns the number of screen lines the header occupies for the +// given config (its content rows plus the top and bottom borders). +func HeaderHeightFor(cfg HeaderConfig) int { + return headerContentRows(cfg) + 2 +} + // RenderHeader renders the full-width header box. // Progressive disclosure as width narrows: first hides the art, then the // info text, keeping keyboard shortcuts always visible. @@ -170,16 +231,32 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) { rightColWidth = maxShortcutWidth + 2 } - // Determine what fits: shortcuts always shown, art and info are progressive. - // Hide art first (below 88 cols), then info text, as width narrows. - showArt := cfg.ShowArt + // Determine what fits: shortcuts always shown, the logo and info are + // progressive. The logo is image-or-nothing: it shows only when an + // inline-image protocol is available and the viewport is wide enough. + showArt := cfg.ShowArt && LogoAvailable() showInfo := true - // Hide art when viewport is too narrow for art + info + shortcuts - if showArt && width < MinWidthForArt { + // Hide the logo when the viewport is too narrow or too short. The height + // guard drops the logo a little before the rest of the header because a + // vertical resize at very short heights can otherwise leave a transient + // ghost of the inline image. The ClearLogo below removes any drawn logo. + if showArt && !artFitsViewport(width, height) { showArt = false } + // The logo image escape, emitted once on the first content row; it spans + // logoImageRows rows and logoImageCols columns in the top-left corner. + logoEsc := "" + if showArt { + logoEsc = renderHeaderLogo(logoImageCols, logoImageRows) + if logoEsc == "" { + showArt = false + } + } + + cr := headerContentRows(cfg) + // If info + shortcuts don't fit, hide info infoMinWidth := 20 // rough minimum for title/info text if innerWidth < rightColWidth+infoMinWidth+4 { @@ -189,13 +266,13 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) { // Map info lines to row indices infoByRow := make(map[int]string) if showInfo { - infoByRow[2] = HeaderTitleStyle.Render(cfg.Title) + infoByRow[0] = HeaderTitleStyle.Render(cfg.Title) if cfg.Subtitle != "" { - infoByRow[3] = HeaderInfoLabelStyle.Render(cfg.Subtitle) + infoByRow[1] = HeaderInfoLabelStyle.Render(cfg.Subtitle) } for i, info := range cfg.InfoLines { - row := 5 + i - if row > 9 { + row := 3 + i + if row > cr-1 { break } iconStyle := HeaderInfoStyle @@ -206,44 +283,53 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) { } } - // Left content base width - leftContentBase := 1 // margin - if showArt { - leftContentBase += ArtDisplayWidth - } - // Vertically center shortcuts scStartRow := 0 if len(shortcuts) > 0 { - scStartRow = (10 - len(shortcuts)) / 2 + scStartRow = (cr - len(shortcuts)) / 2 + if scStartRow < 0 { + scStartRow = 0 + } } - gap := " " + // When the logo is hidden but the terminal could show one (e.g. resized too + // narrow), remove any previously-drawn logo so it does not linger. + if !showArt { + b.WriteString(ClearLogo()) + } // Top border b.WriteString(HeaderBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐")) b.WriteString("\n") - // Content rows - for i := 0; i < 10; i++ { - // Left column: art (optional) + info - artText := "" - if showArt { - artText = ArtLines[i] + // Content rows. The logo occupies the top-left corner across the title and + // subtitle rows, which indent their text past the logo. Every other row (the + // blank spacer and the info lines) starts at the shared left margin, so the + // logo and the info icons line up on the same left edge. + for i := 0; i < cr; i++ { + var left strings.Builder + left.WriteString(strings.Repeat(" ", headerLeftMargin)) + leftWidth := headerLeftMargin + + if showArt && i < logoImageRows { + if i == 0 { + left.WriteString(logoEsc) + } + left.WriteString(strings.Repeat(" ", logoSlotWidth)) + leftWidth += logoSlotWidth } - infoText := "" - infoVisualLen := 0 if info, ok := infoByRow[i]; ok { - infoText = gap + info - infoVisualLen = 2 + lipgloss.Width(info) + left.WriteString(info) + leftWidth += lipgloss.Width(info) } - leftUsed := leftContentBase + infoVisualLen + b.WriteString(HeaderBorderStyle.Render("│")) + b.WriteString(left.String()) if len(shortcuts) > 0 { shortcutCol := innerWidth - rightColWidth - midPad := shortcutCol - leftUsed + midPad := shortcutCol - leftWidth if midPad < 0 { midPad = 0 } @@ -260,31 +346,18 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) { scTrailingPad = 0 } - b.WriteString(HeaderBorderStyle.Render("│")) - b.WriteString(" ") - if showArt { - b.WriteString(artText) - } - b.WriteString(infoText) b.WriteString(strings.Repeat(" ", midPad)) b.WriteString(shortcutRendered) b.WriteString(strings.Repeat(" ", scTrailingPad)) - b.WriteString(HeaderBorderStyle.Render("│")) } else { - trailingPad := innerWidth - leftUsed + trailingPad := innerWidth - leftWidth if trailingPad < 0 { trailingPad = 0 } - - b.WriteString(HeaderBorderStyle.Render("│")) - b.WriteString(" ") - if showArt { - b.WriteString(artText) - } - b.WriteString(infoText) b.WriteString(strings.Repeat(" ", trailingPad)) - b.WriteString(HeaderBorderStyle.Render("│")) } + + b.WriteString(HeaderBorderStyle.Render("│")) b.WriteString("\n") } diff --git a/internal/tui/shared/header_test.go b/internal/tui/shared/header_test.go new file mode 100644 index 0000000..7fcc873 --- /dev/null +++ b/internal/tui/shared/header_test.go @@ -0,0 +1,41 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestArtFitsViewport(t *testing.T) { + tests := []struct { + name string + width, height int + want bool + }{ + {"wide and tall enough", MinWidthForArt, MinHeightForArt, true}, + {"comfortably large", 200, 60, true}, + {"too short by one (resize-artifact band)", MinWidthForArt, MinHeightForArt - 1, false}, + {"too narrow by one", MinWidthForArt - 1, MinHeightForArt, false}, + {"wide but short", 200, 20, false}, + {"tall but narrow", 40, 60, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, artFitsViewport(tt.width, tt.height)) + }) + } +} + +// The logo must hide at a larger height than the header so that, while shrinking +// a tall window, the logo is gone before reaching the short heights where a +// vertical resize leaves a ghost of the inline image. +func TestLogoHidesBeforeHeader(t *testing.T) { + assert.Greater(t, MinHeightForArt, MinHeightForHeader, + "logo height threshold should exceed the header's so the logo hides first") + + // At heights between the two thresholds the header shows but the logo does not. + for h := MinHeightForHeader; h < MinHeightForArt; h++ { + assert.True(t, ShouldShowHeader(MinWidthForArt, h), "header should show at height %d", h) + assert.False(t, artFitsViewport(MinWidthForArt, h), "logo should be hidden at height %d", h) + } +} diff --git a/internal/tui/shared/logo.go b/internal/tui/shared/logo.go new file mode 100644 index 0000000..9a8add7 --- /dev/null +++ b/internal/tui/shared/logo.go @@ -0,0 +1,172 @@ +package shared + +import ( + _ "embed" + "encoding/base64" + "os" + "strconv" + "strings" + "sync" + + "github.com/BourgeoisBear/rasterm" + "golang.org/x/term" +) + +// invertocatPNG is the white GitHub Invertocat mark, embedded so it never has to +// be fetched at runtime. It is drawn via an inline-image protocol; there is no +// character-art fallback. +// +//go:embed assets/invertocat-white.png +var invertocatPNG []byte + +// headerLogoID is a fixed kitty image id so re-emitting the header replaces the +// logo in place (and lets us delete it by id) instead of stacking copies. +const headerLogoID = 7 + +// Cursor save/restore (DECSC/DECRC). Drawing an inline image moves the terminal +// cursor; wrapping the escape in save/restore returns the cursor to where the +// renderer expects it, so the image does not push the rest of the frame down. +const ( + cursorSave = "\x1b7" + cursorRestore = "\x1b8" +) + +// logoProto identifies the inline-image protocol to use for the logo. +type logoProto int + +const ( + logoNone logoProto = iota + logoKitty + logoIterm +) + +var ( + logoDetectOnce sync.Once + logoProtocol logoProto + logoBase64 string + logoBase64Once sync.Once +) + +// detectLogoProtocol decides — once, at first use — whether an inline-image +// protocol is available. Detection is environment-based only (no terminal +// round-trip queries) so it never blocks or interferes with the alt-screen TUI. +// The logo is hidden unless stdout is a real TTY and a supported protocol is +// detected, and always hidden inside tmux/screen to avoid a garbled header. +func detectLogoProtocol() logoProto { + logoDetectOnce.Do(func() { + logoProtocol = logoNone + if !term.IsTerminal(int(os.Stdout.Fd())) { + return + } + if rasterm.IsTmuxScreen() { + return + } + switch { + case rasterm.IsKittyCapable(): + logoProtocol = logoKitty + case rasterm.IsItermCapable(): + logoProtocol = logoIterm + } + }) + return logoProtocol +} + +func pngBase64() string { + logoBase64Once.Do(func() { + logoBase64 = base64.StdEncoding.EncodeToString(invertocatPNG) + }) + return logoBase64 +} + +// LogoAvailable reports whether the inline-image logo can be drawn (a supported +// protocol is detected on a real TTY outside tmux/screen). When false, the +// header shows no logo at all — there is no ASCII fallback. +func LogoAvailable() bool { + return detectLogoProtocol() != logoNone +} + +// ClearLogo returns an escape that removes a previously-drawn logo. kitty +// graphics live in a layer the text renderer cannot clear, so callers must emit +// this when they stop drawing the header (e.g. the header is hidden) to keep the +// image from lingering. It returns "" when there is nothing to clear (iTerm2 +// images occupy text cells and are overwritten normally; no protocol => nothing). +func ClearLogo() string { + if detectLogoProtocol() == logoKitty { + return "\x1b_Ga=d,d=i,i=" + strconv.Itoa(headerLogoID) + ",q=2\x1b\\" + } + return "" +} + +// renderHeaderLogo returns the inline-image escape for the embedded Invertocat, +// sized to about cols cells wide (and, for the square mark, ~cols/2 cells tall, +// since a terminal cell is roughly twice as tall as it is wide). Both protocols +// preserve the mark's aspect: iTerm2 fits it within the cols x rows box and +// kitty scales it to cols cells wide. The escape is wrapped in cursor +// save/restore so it never displaces the surrounding text. Returns "" when no +// inline-image protocol is available. +func renderHeaderLogo(cols, rows int) string { + if cols < 1 || rows < 1 { + return "" + } + switch detectLogoProtocol() { + case logoKitty: + return cursorSave + kittyPlaceLogo(cols) + cursorRestore + case logoIterm: + return cursorSave + itermPlaceLogo(cols, rows) + cursorRestore + default: + return "" + } +} + +// kittyPlaceLogo builds a kitty graphics escape that transmits the embedded PNG +// and displays it cols cells wide. Only c is sent (not r), so kitty derives the +// height from the mark's square aspect (about cols/2 cells) and never stretches +// it. C=1 keeps the cursor from moving and q=2 suppresses the terminal's +// responses (which would otherwise corrupt the TUI). The base64 payload is +// chunked per the protocol. +func kittyPlaceLogo(cols int) string { + const chunkSize = 4096 + data := pngBase64() + var sb strings.Builder + first := true + for { + n := chunkSize + if n > len(data) { + n = len(data) + } + chunk := data[:n] + data = data[n:] + more := 0 + if len(data) > 0 { + more = 1 + } + sb.WriteString("\x1b_G") + if first { + sb.WriteString("f=100,a=T,i=") + sb.WriteString(strconv.Itoa(headerLogoID)) + sb.WriteString(",q=2,C=1,c=") + sb.WriteString(strconv.Itoa(cols)) + sb.WriteString(",m=") + sb.WriteString(strconv.Itoa(more)) + first = false + } else { + sb.WriteString("m=") + sb.WriteString(strconv.Itoa(more)) + } + sb.WriteString(";") + sb.WriteString(chunk) + sb.WriteString("\x1b\\") + if more == 0 { + break + } + } + return sb.String() +} + +// itermPlaceLogo builds an iTerm2 inline-image escape (OSC 1337) sized to +// cols x rows cells with the aspect ratio preserved. +func itermPlaceLogo(cols, rows int) string { + return "\x1b]1337;File=inline=1;preserveAspectRatio=1;width=" + + strconv.Itoa(cols) + ";height=" + strconv.Itoa(rows) + ":" + + pngBase64() + "\x07" +} diff --git a/internal/tui/shared/render.go b/internal/tui/shared/render.go index 65bacf2..af594a9 100644 --- a/internal/tui/shared/render.go +++ b/internal/tui/shared/render.go @@ -401,24 +401,24 @@ func TimeAgo(t time.Time) string { // ClickResult describes what happened when a node was clicked. type ClickResult struct { - NodeIndex int // which node was clicked (-1 if none) - ToggleFiles bool // should toggle files expansion - ToggleCommits bool // should toggle commits expansion - OpenURL string // URL to open in browser (empty if none) + NodeIndex int // which node was clicked (-1 if none) + ToggleFiles bool // should toggle files expansion + ToggleCommits bool // should toggle commits expansion + OpenURL string // URL to open in browser (empty if none) } // HandleClick maps a screen click to a node action. // nodes is the list of BranchNodeData in display order. -// showHeader indicates whether the header is visible. +// headerHeight is the number of rows the header occupies (0 when it is hidden). // scrollOffset is the current scroll position. // hasSeparators controls whether merged/queued separator lines are accounted for. -func HandleClick(screenX, screenY int, nodes []BranchNodeData, width, height, scrollOffset int, showHeader, hasSeparators bool) ClickResult { +func HandleClick(screenX, screenY int, nodes []BranchNodeData, width, height, scrollOffset int, headerHeight int, hasSeparators bool) ClickResult { yOffset := 0 - if showHeader { - if screenY < HeaderHeight { + if headerHeight > 0 { + if screenY < headerHeight { return ClickResult{NodeIndex: -1} } - yOffset = HeaderHeight + yOffset = headerHeight } contentLine := (screenY - yOffset) + scrollOffset diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index 49cd46f..db2df7f 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -225,7 +225,7 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) { nodes[i] = toBranchNodeData(n) } - result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, shared.ShouldShowHeader(m.width, m.height), true) + result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, m.headerHeight(), true) if result.NodeIndex < 0 { return m, nil } @@ -329,13 +329,18 @@ func (m Model) totalContentLines() int { return lines } -// contentViewHeight returns the number of lines available for stack content. -func (m Model) contentViewHeight() int { - reserved := 0 +// headerHeight returns the number of rows the header occupies for this model's +// config, or 0 when the header is hidden. +func (m Model) headerHeight() int { if shared.ShouldShowHeader(m.width, m.height) { - reserved = shared.HeaderHeight + return shared.HeaderHeightFor(m.buildHeaderConfig()) } - h := m.height - reserved + return 0 +} + +// contentViewHeight returns the number of lines available for stack content. +func (m Model) contentViewHeight() int { + h := m.height - m.headerHeight() if h < 1 { h = 1 } @@ -357,6 +362,10 @@ func (m Model) View() string { showHeader := shared.ShouldShowHeader(m.width, m.height) if showHeader { shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height) + } else { + // The header (and its inline-image logo) is hidden; clear any logo that + // was previously drawn so it does not linger in the graphics layer. + out.WriteString(shared.ClearLogo()) } var b strings.Builder @@ -383,10 +392,7 @@ func (m Model) View() string { content := b.String() // Apply scrolling - reservedLines := 0 - if showHeader { - reservedLines = shared.HeaderHeight - } + reservedLines := m.headerHeight() viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 @@ -440,8 +446,7 @@ func (m Model) buildHeaderConfig() shared.HeaderConfig { }, ShortcutColumns: 1, Shortcuts: []shared.ShortcutEntry{ - {Key: "↑", Desc: "up"}, - {Key: "↓", Desc: "down"}, + {Key: "↑↓", Desc: "navigate"}, {Key: "c", Desc: "commits"}, {Key: "f", Desc: "files"}, {Key: "o", Desc: "open PR"}, From 6f6cefa82aeb927ce290b81682a32b0d7a8a10a7 Mon Sep 17 00:00:00 2001 From: Sameen Karim Date: Wed, 24 Jun 2026 13:02:42 -0400 Subject: [PATCH 2/2] Drop the unused HeaderHeight constant and dedupe the header config build Follow-up to review feedback on the shared-header redesign: - Remove the HeaderHeight constant. Nothing referenced it (callers compute height via HeaderHeightFor), and its doc described a "maximum" that HeaderHeightFor does not actually enforce, so the comment was misleading. - In the view and modify View() methods, build the header config once and reuse it for both RenderHeader and the height reservation instead of rebuilding it twice per frame. The click/scroll handlers keep deriving the height from the same config, so the header's dimensions remain a single source of truth. --- internal/tui/modifyview/model.go | 9 +++++++-- internal/tui/shared/header.go | 6 ------ internal/tui/stackview/model.go | 8 ++++++-- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/internal/tui/modifyview/model.go b/internal/tui/modifyview/model.go index debeec3..839ad86 100644 --- a/internal/tui/modifyview/model.go +++ b/internal/tui/modifyview/model.go @@ -1361,8 +1361,13 @@ func (m Model) View() string { // Header showHeader := shared.ShouldShowHeader(m.width, m.height) + headerLines := 0 if showHeader { - shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height) + // Build the header config once and reuse it for both rendering and the + // height reservation, so View does not rebuild it twice per frame. + cfg := m.buildHeaderConfig() + shared.RenderHeader(&out, cfg, m.width, m.height) + headerLines = shared.HeaderHeightFor(cfg) } else { // The header (and its inline-image logo) is hidden; clear any logo that // was previously drawn so it does not linger in the graphics layer. @@ -1392,7 +1397,7 @@ func (m Model) View() string { bottomLines := 2 // error/status line + status bar (post-scroll newline is inline) // Scrolling — reserve space for header and fixed bottom - reservedLines := bottomLines + m.headerHeight() + reservedLines := bottomLines + headerLines viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 diff --git a/internal/tui/shared/header.go b/internal/tui/shared/header.go index 68f15c1..e00ac14 100644 --- a/internal/tui/shared/header.go +++ b/internal/tui/shared/header.go @@ -6,12 +6,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -// HeaderHeight is the maximum number of lines the header occupies (a top border, -// the content rows, and a bottom border). The actual height for a given config -// is HeaderHeightFor, which sizes the box to its content with no trailing empty -// rows. -const HeaderHeight = 9 - // MinHeightForHeader is the minimum terminal height to show the header. const MinHeightForHeader = 25 diff --git a/internal/tui/stackview/model.go b/internal/tui/stackview/model.go index db2df7f..a2997ae 100644 --- a/internal/tui/stackview/model.go +++ b/internal/tui/stackview/model.go @@ -360,8 +360,13 @@ func (m Model) View() string { var out strings.Builder showHeader := shared.ShouldShowHeader(m.width, m.height) + reservedLines := 0 if showHeader { - shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height) + // Build the header config once and reuse it for both rendering and the + // height reservation, so View does not rebuild it twice per frame. + cfg := m.buildHeaderConfig() + shared.RenderHeader(&out, cfg, m.width, m.height) + reservedLines = shared.HeaderHeightFor(cfg) } else { // The header (and its inline-image logo) is hidden; clear any logo that // was previously drawn so it does not linger in the graphics layer. @@ -392,7 +397,6 @@ func (m Model) View() string { content := b.String() // Apply scrolling - reservedLines := m.headerHeight() viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1