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..839ad86 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 } @@ -1355,8 +1361,17 @@ 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. + out.WriteString(shared.ClearLogo()) } // Build the scrollable branch list content @@ -1382,10 +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 - if showHeader { - reservedLines += shared.HeaderHeight - } + reservedLines := bottomLines + headerLines 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 0000000..6e9c4d9 Binary files /dev/null and b/internal/tui/shared/assets/invertocat-white.png differ diff --git a/internal/tui/shared/header.go b/internal/tui/shared/header.go index edec801..e00ac14 100644 --- a/internal/tui/shared/header.go +++ b/internal/tui/shared/header.go @@ -6,9 +6,6 @@ import ( "github.com/charmbracelet/lipgloss" ) -// HeaderHeight is the total number of lines the header occupies. -const HeaderHeight = 12 - // MinHeightForHeader is the minimum terminal height to show the header. const MinHeightForHeader = 25 @@ -18,9 +15,16 @@ const MinWidthForShortcuts = 65 // MinWidthForHeader is the minimum width to show the header at all. const MinWidthForHeader = 53 -// MinWidthForArt is the minimum width to show ASCII art in the header. +// MinWidthForArt is the minimum width to show the logo in the header. const MinWidthForArt = 96 +// MinHeightForArt is the minimum terminal height to show the logo. It is a bit +// higher than MinHeightForHeader: at very short heights a vertical resize can +// leave a transient ghost of the inline image (kitty graphics live in a layer +// the text renderer can't repaint cleanly mid-resize), so the logo is dropped a +// little before the rest of the header to avoid the artifact. +const MinHeightForArt = 30 + // ShortcutEntry represents a keyboard shortcut for the header. type ShortcutEntry struct { Key string @@ -35,22 +39,31 @@ type HeaderInfoLine struct { IconStyle *lipgloss.Style // optional override; nil uses default HeaderInfoStyle (cyan) } -// ArtLines is the braille ASCII art for the View header. -var ArtLines = [10]string{ - "⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀", - "⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀", - "⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀", - "⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀", - "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", - "⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇", - "⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇", - "⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀", - "⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀", - "⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀", -} +// headerLeftMargin is the left padding, in columns, before the logo and the +// info lines (which share this left edge). It is kept small so it visually +// matches the header's top and bottom padding. +const headerLeftMargin = 1 + +// The logo image sits in the top-left corner spanning the title and subtitle +// rows. logoImageCols is its width in cells, which drives the size: the mark is +// square and a terminal cell is about twice as tall as it is wide, so the logo +// renders about logoImageCols/2 cells tall. Width is the controlled dimension +// (kitty scales the square mark to logoImageCols cells wide; iTerm2 fits it +// within logoImageCols x logoImageRows), so the slot width is exact. 4 cols +// gives a ~2-cell-tall logo. logoImageRows bounds the height (and the layout +// slot's rows). +const ( + logoImageCols = 4 + logoImageRows = 2 +) + +// logoTextGap is the number of blank columns between the logo and the title / +// subtitle text, so the heading has a little room to breathe. +const logoTextGap = 2 -// ArtDisplayWidth is the visual column width of each art line. -const ArtDisplayWidth = 20 +// logoSlotWidth is the width reserved on the logo rows: the logo image plus the +// gap before the title and subtitle text. +const logoSlotWidth = logoImageCols + logoTextGap // HeaderConfig controls what the header displays. type HeaderConfig struct { @@ -72,6 +85,48 @@ func ShouldShowShortcuts(width int) bool { return width >= 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 +225,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 +260,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 +277,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 +340,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..a2997ae 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 } @@ -355,8 +360,17 @@ 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. + out.WriteString(shared.ClearLogo()) } var b strings.Builder @@ -383,10 +397,6 @@ func (m Model) View() string { content := b.String() // Apply scrolling - reservedLines := 0 - if showHeader { - reservedLines = shared.HeaderHeight - } viewHeight := m.height - reservedLines if viewHeight < 1 { viewHeight = 1 @@ -440,8 +450,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"},