Skip to content

Commit a14cb55

Browse files
committed
Redesign the shared header and embed the GitHub logo as an image
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.
1 parent 67d118f commit a14cb55

9 files changed

Lines changed: 396 additions & 93 deletions

File tree

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.26.0
44

55
require (
66
github.com/AlecAivazis/survey/v2 v2.3.7
7+
github.com/BourgeoisBear/rasterm v1.1.2
78
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
89
github.com/charmbracelet/bubbletea v1.3.10
910
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
@@ -14,6 +15,7 @@ require (
1415
github.com/spf13/cobra v1.10.2
1516
github.com/stretchr/testify v1.11.1
1617
golang.org/x/sys v0.45.0
18+
golang.org/x/term v0.43.0
1719
golang.org/x/text v0.37.0
1820
)
1921

@@ -49,6 +51,5 @@ require (
4951
github.com/spf13/pflag v1.0.10 // indirect
5052
github.com/thlib/go-timezone-local v0.0.6 // indirect
5153
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
52-
golang.org/x/term v0.43.0 // indirect
5354
gopkg.in/yaml.v3 v3.0.1 // indirect
5455
)

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
22
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
3+
github.com/BourgeoisBear/rasterm v1.1.2 h1:hWHZBZ45N366uNSqxWFYBV0y19q8fXRXADhPkoLF4Ss=
4+
github.com/BourgeoisBear/rasterm v1.1.2/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw=
35
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
46
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
57
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
135137
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
136138
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
137139
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
140+
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
138141
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
139142
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
140143
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
141144
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
145+
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
142146
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
143147
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
144148
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

internal/tui/modifyview/model.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,11 +1191,17 @@ func (m Model) nodeLineCount(idx int) int {
11911191
return shared.NodeLineCount(toNodeData(m.nodes[idx], idx, idx))
11921192
}
11931193

1194-
func (m Model) contentViewHeight() int {
1195-
reserved := 3 // post-scroll newline + context line + status bar
1194+
// headerHeight returns the number of rows the header occupies for this model's
1195+
// config, or 0 when the header is hidden.
1196+
func (m Model) headerHeight() int {
11961197
if shared.ShouldShowHeader(m.width, m.height) {
1197-
reserved += shared.HeaderHeight
1198+
return shared.HeaderHeightFor(m.buildHeaderConfig())
11981199
}
1200+
return 0
1201+
}
1202+
1203+
func (m Model) contentViewHeight() int {
1204+
reserved := 3 + m.headerHeight() // post-scroll newline + context line + status bar
11991205
h := m.height - reserved
12001206
if h < 1 {
12011207
h = 1
@@ -1221,7 +1227,7 @@ func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) {
12211227
nodes[i] = toNodeData(n, i, i)
12221228
}
12231229

1224-
result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, shared.ShouldShowHeader(m.width, m.height), false)
1230+
result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, m.headerHeight(), false)
12251231
if result.NodeIndex < 0 {
12261232
return m, nil
12271233
}
@@ -1357,6 +1363,10 @@ func (m Model) View() string {
13571363
showHeader := shared.ShouldShowHeader(m.width, m.height)
13581364
if showHeader {
13591365
shared.RenderHeader(&out, m.buildHeaderConfig(), m.width, m.height)
1366+
} else {
1367+
// The header (and its inline-image logo) is hidden; clear any logo that
1368+
// was previously drawn so it does not linger in the graphics layer.
1369+
out.WriteString(shared.ClearLogo())
13601370
}
13611371

13621372
// Build the scrollable branch list content
@@ -1382,10 +1392,7 @@ func (m Model) View() string {
13821392
bottomLines := 2 // error/status line + status bar (post-scroll newline is inline)
13831393

13841394
// Scrolling — reserve space for header and fixed bottom
1385-
reservedLines := bottomLines
1386-
if showHeader {
1387-
reservedLines += shared.HeaderHeight
1388-
}
1395+
reservedLines := bottomLines + m.headerHeight()
13891396
viewHeight := m.height - reservedLines
13901397
if viewHeight < 1 {
13911398
viewHeight = 1
2.61 KB
Loading

internal/tui/shared/header.go

Lines changed: 136 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import (
66
"github.com/charmbracelet/lipgloss"
77
)
88

9-
// HeaderHeight is the total number of lines the header occupies.
10-
const HeaderHeight = 12
9+
// HeaderHeight is the maximum number of lines the header occupies (a top border,
10+
// the content rows, and a bottom border). The actual height for a given config
11+
// is HeaderHeightFor, which sizes the box to its content with no trailing empty
12+
// rows.
13+
const HeaderHeight = 9
1114

1215
// MinHeightForHeader is the minimum terminal height to show the header.
1316
const MinHeightForHeader = 25
@@ -18,9 +21,16 @@ const MinWidthForShortcuts = 65
1821
// MinWidthForHeader is the minimum width to show the header at all.
1922
const MinWidthForHeader = 53
2023

21-
// MinWidthForArt is the minimum width to show ASCII art in the header.
24+
// MinWidthForArt is the minimum width to show the logo in the header.
2225
const MinWidthForArt = 96
2326

27+
// MinHeightForArt is the minimum terminal height to show the logo. It is a bit
28+
// higher than MinHeightForHeader: at very short heights a vertical resize can
29+
// leave a transient ghost of the inline image (kitty graphics live in a layer
30+
// the text renderer can't repaint cleanly mid-resize), so the logo is dropped a
31+
// little before the rest of the header to avoid the artifact.
32+
const MinHeightForArt = 30
33+
2434
// ShortcutEntry represents a keyboard shortcut for the header.
2535
type ShortcutEntry struct {
2636
Key string
@@ -35,22 +45,31 @@ type HeaderInfoLine struct {
3545
IconStyle *lipgloss.Style // optional override; nil uses default HeaderInfoStyle (cyan)
3646
}
3747

38-
// ArtLines is the braille ASCII art for the View header.
39-
var ArtLines = [10]string{
40-
"⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀",
41-
"⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀",
42-
"⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀",
43-
"⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀",
44-
"⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇",
45-
"⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇",
46-
"⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇",
47-
"⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀",
48-
"⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀",
49-
"⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀",
50-
}
48+
// headerLeftMargin is the left padding, in columns, before the logo and the
49+
// info lines (which share this left edge). It is kept small so it visually
50+
// matches the header's top and bottom padding.
51+
const headerLeftMargin = 1
52+
53+
// The logo image sits in the top-left corner spanning the title and subtitle
54+
// rows. logoImageCols is its width in cells, which drives the size: the mark is
55+
// square and a terminal cell is about twice as tall as it is wide, so the logo
56+
// renders about logoImageCols/2 cells tall. Width is the controlled dimension
57+
// (kitty scales the square mark to logoImageCols cells wide; iTerm2 fits it
58+
// within logoImageCols x logoImageRows), so the slot width is exact. 4 cols
59+
// gives a ~2-cell-tall logo. logoImageRows bounds the height (and the layout
60+
// slot's rows).
61+
const (
62+
logoImageCols = 4
63+
logoImageRows = 2
64+
)
5165

52-
// ArtDisplayWidth is the visual column width of each art line.
53-
const ArtDisplayWidth = 20
66+
// logoTextGap is the number of blank columns between the logo and the title /
67+
// subtitle text, so the heading has a little room to breathe.
68+
const logoTextGap = 2
69+
70+
// logoSlotWidth is the width reserved on the logo rows: the logo image plus the
71+
// gap before the title and subtitle text.
72+
const logoSlotWidth = logoImageCols + logoTextGap
5473

5574
// HeaderConfig controls what the header displays.
5675
type HeaderConfig struct {
@@ -72,6 +91,48 @@ func ShouldShowShortcuts(width int) bool {
7291
return width >= MinWidthForShortcuts
7392
}
7493

94+
// artFitsViewport reports whether the viewport is wide and tall enough to show
95+
// the logo. The height bound (MinHeightForArt) is a little above
96+
// MinHeightForHeader so the logo is dropped before the header itself at short
97+
// heights, where a vertical resize can otherwise leave a transient ghost of the
98+
// inline image.
99+
func artFitsViewport(width, height int) bool {
100+
return width >= MinWidthForArt && height >= MinHeightForArt
101+
}
102+
103+
// shortcutRowCount returns how many rows the shortcut block occupies for the
104+
// config's column count.
105+
func shortcutRowCount(cfg HeaderConfig) int {
106+
n := len(cfg.Shortcuts)
107+
if n == 0 {
108+
return 0
109+
}
110+
cols := cfg.ShortcutColumns
111+
if cols < 1 {
112+
cols = 1
113+
}
114+
return (n + cols - 1) / cols
115+
}
116+
117+
// headerContentRows returns how many content rows the header needs: enough for
118+
// the title/subtitle/info block or the shortcut block, whichever is taller. This
119+
// keeps the box exactly as tall as its content, with no trailing empty row.
120+
func headerContentRows(cfg HeaderConfig) int {
121+
// title (row 0), subtitle (row 1), a gap (row 2), then the info lines.
122+
info := 3 + len(cfg.InfoLines)
123+
sc := shortcutRowCount(cfg)
124+
if sc > info {
125+
return sc
126+
}
127+
return info
128+
}
129+
130+
// HeaderHeightFor returns the number of screen lines the header occupies for the
131+
// given config (its content rows plus the top and bottom borders).
132+
func HeaderHeightFor(cfg HeaderConfig) int {
133+
return headerContentRows(cfg) + 2
134+
}
135+
75136
// RenderHeader renders the full-width header box.
76137
// Progressive disclosure as width narrows: first hides the art, then the
77138
// info text, keeping keyboard shortcuts always visible.
@@ -170,16 +231,32 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) {
170231
rightColWidth = maxShortcutWidth + 2
171232
}
172233

173-
// Determine what fits: shortcuts always shown, art and info are progressive.
174-
// Hide art first (below 88 cols), then info text, as width narrows.
175-
showArt := cfg.ShowArt
234+
// Determine what fits: shortcuts always shown, the logo and info are
235+
// progressive. The logo is image-or-nothing: it shows only when an
236+
// inline-image protocol is available and the viewport is wide enough.
237+
showArt := cfg.ShowArt && LogoAvailable()
176238
showInfo := true
177239

178-
// Hide art when viewport is too narrow for art + info + shortcuts
179-
if showArt && width < MinWidthForArt {
240+
// Hide the logo when the viewport is too narrow or too short. The height
241+
// guard drops the logo a little before the rest of the header because a
242+
// vertical resize at very short heights can otherwise leave a transient
243+
// ghost of the inline image. The ClearLogo below removes any drawn logo.
244+
if showArt && !artFitsViewport(width, height) {
180245
showArt = false
181246
}
182247

248+
// The logo image escape, emitted once on the first content row; it spans
249+
// logoImageRows rows and logoImageCols columns in the top-left corner.
250+
logoEsc := ""
251+
if showArt {
252+
logoEsc = renderHeaderLogo(logoImageCols, logoImageRows)
253+
if logoEsc == "" {
254+
showArt = false
255+
}
256+
}
257+
258+
cr := headerContentRows(cfg)
259+
183260
// If info + shortcuts don't fit, hide info
184261
infoMinWidth := 20 // rough minimum for title/info text
185262
if innerWidth < rightColWidth+infoMinWidth+4 {
@@ -189,13 +266,13 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) {
189266
// Map info lines to row indices
190267
infoByRow := make(map[int]string)
191268
if showInfo {
192-
infoByRow[2] = HeaderTitleStyle.Render(cfg.Title)
269+
infoByRow[0] = HeaderTitleStyle.Render(cfg.Title)
193270
if cfg.Subtitle != "" {
194-
infoByRow[3] = HeaderInfoLabelStyle.Render(cfg.Subtitle)
271+
infoByRow[1] = HeaderInfoLabelStyle.Render(cfg.Subtitle)
195272
}
196273
for i, info := range cfg.InfoLines {
197-
row := 5 + i
198-
if row > 9 {
274+
row := 3 + i
275+
if row > cr-1 {
199276
break
200277
}
201278
iconStyle := HeaderInfoStyle
@@ -206,44 +283,53 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) {
206283
}
207284
}
208285

209-
// Left content base width
210-
leftContentBase := 1 // margin
211-
if showArt {
212-
leftContentBase += ArtDisplayWidth
213-
}
214-
215286
// Vertically center shortcuts
216287
scStartRow := 0
217288
if len(shortcuts) > 0 {
218-
scStartRow = (10 - len(shortcuts)) / 2
289+
scStartRow = (cr - len(shortcuts)) / 2
290+
if scStartRow < 0 {
291+
scStartRow = 0
292+
}
219293
}
220294

221-
gap := " "
295+
// When the logo is hidden but the terminal could show one (e.g. resized too
296+
// narrow), remove any previously-drawn logo so it does not linger.
297+
if !showArt {
298+
b.WriteString(ClearLogo())
299+
}
222300

223301
// Top border
224302
b.WriteString(HeaderBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐"))
225303
b.WriteString("\n")
226304

227-
// Content rows
228-
for i := 0; i < 10; i++ {
229-
// Left column: art (optional) + info
230-
artText := ""
231-
if showArt {
232-
artText = ArtLines[i]
305+
// Content rows. The logo occupies the top-left corner across the title and
306+
// subtitle rows, which indent their text past the logo. Every other row (the
307+
// blank spacer and the info lines) starts at the shared left margin, so the
308+
// logo and the info icons line up on the same left edge.
309+
for i := 0; i < cr; i++ {
310+
var left strings.Builder
311+
left.WriteString(strings.Repeat(" ", headerLeftMargin))
312+
leftWidth := headerLeftMargin
313+
314+
if showArt && i < logoImageRows {
315+
if i == 0 {
316+
left.WriteString(logoEsc)
317+
}
318+
left.WriteString(strings.Repeat(" ", logoSlotWidth))
319+
leftWidth += logoSlotWidth
233320
}
234321

235-
infoText := ""
236-
infoVisualLen := 0
237322
if info, ok := infoByRow[i]; ok {
238-
infoText = gap + info
239-
infoVisualLen = 2 + lipgloss.Width(info)
323+
left.WriteString(info)
324+
leftWidth += lipgloss.Width(info)
240325
}
241326

242-
leftUsed := leftContentBase + infoVisualLen
327+
b.WriteString(HeaderBorderStyle.Render("│"))
328+
b.WriteString(left.String())
243329

244330
if len(shortcuts) > 0 {
245331
shortcutCol := innerWidth - rightColWidth
246-
midPad := shortcutCol - leftUsed
332+
midPad := shortcutCol - leftWidth
247333
if midPad < 0 {
248334
midPad = 0
249335
}
@@ -260,31 +346,18 @@ func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) {
260346
scTrailingPad = 0
261347
}
262348

263-
b.WriteString(HeaderBorderStyle.Render("│"))
264-
b.WriteString(" ")
265-
if showArt {
266-
b.WriteString(artText)
267-
}
268-
b.WriteString(infoText)
269349
b.WriteString(strings.Repeat(" ", midPad))
270350
b.WriteString(shortcutRendered)
271351
b.WriteString(strings.Repeat(" ", scTrailingPad))
272-
b.WriteString(HeaderBorderStyle.Render("│"))
273352
} else {
274-
trailingPad := innerWidth - leftUsed
353+
trailingPad := innerWidth - leftWidth
275354
if trailingPad < 0 {
276355
trailingPad = 0
277356
}
278-
279-
b.WriteString(HeaderBorderStyle.Render("│"))
280-
b.WriteString(" ")
281-
if showArt {
282-
b.WriteString(artText)
283-
}
284-
b.WriteString(infoText)
285357
b.WriteString(strings.Repeat(" ", trailingPad))
286-
b.WriteString(HeaderBorderStyle.Render("│"))
287358
}
359+
360+
b.WriteString(HeaderBorderStyle.Render("│"))
288361
b.WriteString("\n")
289362
}
290363

0 commit comments

Comments
 (0)