Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)

Expand Down Expand Up @@ -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
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down Expand Up @@ -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=
Expand Down
30 changes: 21 additions & 9 deletions internal/tui/modifyview/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Comment thread
skarim marked this conversation as resolved.
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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Binary file added internal/tui/shared/assets/invertocat-white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
195 changes: 131 additions & 64 deletions internal/tui/shared/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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")
}

Expand Down
Loading
Loading