Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 15 additions & 8 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 @@ -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
Expand All @@ -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
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.
199 changes: 136 additions & 63 deletions internal/tui/shared/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import (
"github.com/charmbracelet/lipgloss"
)

// HeaderHeight is the total number of lines the header occupies.
const HeaderHeight = 12
// 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
Comment thread
skarim marked this conversation as resolved.
Outdated

// MinHeightForHeader is the minimum terminal height to show the header.
const MinHeightForHeader = 25
Expand All @@ -18,9 +21,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 +45,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
)

// ArtDisplayWidth is the visual column width of each art line.
const ArtDisplayWidth = 20
// 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

// 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 +91,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 +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 {
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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")
}

Expand Down
Loading
Loading