package ui import ( "fmt" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/lipgloss/v2" "git.morlana.online/f.weber/vault-tui/internal/cli" ) // screenHelp is the shape every keys.KeyMap.XxxHelp() screen-help value // satisfies; declared here (rather than importing the concrete type, which // is unexported in package keys) so viewFooter can take any of them. type screenHelp interface { ShortHelp() []key.Binding FullHelp() [][]key.Binding } var _ help.KeyMap = screenHelp(nil) // screenHelp is exactly help.KeyMap // breadcrumbSegments returns one path element per frame on the stack, plus // the leading profile name — the pieces viewHeader joins with symChevronR // and, on a narrow terminal, elides from the middle. func (m *Model) breadcrumbSegments() []string { segs := []string{m.a.Settings.Profile} for _, f := range m.stack { switch f.scr { case scrMounts: segs = append(segs, "mounts") case scrBrowser, scrSecret: segs = append(segs, f.mount.Path+f.path) case scrEditor: segs = append(segs, "edit") case scrVersions: segs = append(segs, "versions") } } return segs } // renderBreadcrumb joins breadcrumbSegments with " ▸ ", eliding from the // middle (keeping the profile name and the current location legible) when // the full trail doesn't fit in avail cells. func (m *Model) renderBreadcrumb(avail int) string { segs := m.breadcrumbSegments() sep := m.styles.CrumbSep.Render(" " + symChevronR + " ") join := func(ss []string) string { styled := make([]string, len(ss)) last := len(ss) - 1 for i, s := range ss { if i == last { styled[i] = m.styles.CrumbActive.Render(s) } else { styled[i] = m.styles.Crumb.Render(s) } } out := styled[0] for _, s := range styled[1:] { out += sep + s } return out } full := join(segs) if lipgloss.Width(full) <= avail || len(segs) <= 2 { if lipgloss.Width(full) <= avail { return full } return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail)) } elided := append([]string{segs[0], symEllipsis}, segs[len(segs)-1]) out := join(elided) if lipgloss.Width(out) <= avail { return out } return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail)) } // ttlPill renders the token-TTL pill, colored by how close it is to // Settings.TTLWarnBelow. func (m *Model) ttlPill() string { if m.tokenInfo == nil || m.tokenInfo.ExpireTime == nil { return "" } ttl := m.tokenInfo.TTL text := "ttl " + ttlString(ttl) warnAt := m.a.Settings.TTLWarnBelow switch { case ttl <= 0: return m.styles.PillDanger.Render(text) case warnAt > 0 && ttl <= warnAt/4: return m.styles.PillDanger.Render(text) case warnAt > 0 && ttl <= warnAt: return m.styles.PillWarn.Render(text) default: return m.styles.Pill.Render(text) } } // viewHeader renders the single-line top bar: app badge + breadcrumb on // the left, status pills on the right. Collapses to a bare breadcrumb on // sizeTiny, where there's no room for a badge. func (m *Model) viewHeader() string { var right []string if m.a.Settings.ReadOnly { right = append(right, m.styles.PillWarn.Render("READ-ONLY")) } if m.a.Settings.Namespace != "" && m.lay.class != sizeTiny { right = append(right, m.styles.Pill.Render("ns:"+m.a.Settings.Namespace)) } if p := m.ttlPill(); p != "" { right = append(right, p) } if m.loading { right = append(right, m.spin.View()) } rightStr := lipgloss.JoinHorizontal(lipgloss.Center, right...) rightW := lipgloss.Width(rightStr) if rightStr != "" { rightStr = " " + rightStr rightW++ } badge := "" if m.lay.class != sizeTiny { badge = m.styles.AppBadge.Render("▮ VAULT") + " " } badgeW := lipgloss.Width(badge) // HeaderBar carries its own Padding(0,1) — a style's declared Width sets // the pre-padding content width, so the bar renders at contentW+2. Budget // for that here rather than sizing to the full bar width, or the result // overflows the terminal by 2 cells and wraps. barW := m.lay.w - 2 if barW < 1 { barW = 1 } avail := barW - badgeW - rightW if avail < 4 { avail = 4 } crumb := m.renderBreadcrumb(avail) crumbW := lipgloss.Width(crumb) pad := barW - badgeW - crumbW - rightW if pad < 0 { pad = 0 } line := badge + crumb + lipgloss.NewStyle().Width(pad).Render("") + rightStr return m.styles.HeaderBar.Render(fit(line, barW, 1)) } // viewFooter renders the footer: a screen-specific key hint line plus a // status line (profile/address, or the current toast) — one line total on // sizeTiny, two otherwise. func (m *Model) viewFooter(sh screenHelp) string { b := cli.Build() ver := b.Version if b.Prerelease { ver += " (prerelease)" } status := fmt.Sprintf("%s @ %s · %s", m.a.Settings.Profile, m.a.Settings.Address, ver) if toastStr := m.toastView(); toastStr != "" { status = toastStr } else { status = m.styles.StatusBar.Render(status) } // Footer carries Padding(0,1) too — same barW budgeting as viewHeader. barW := m.lay.w - 2 if barW < 1 { barW = 1 } m.help.SetWidth(barW) if m.lay.class == sizeTiny { line := status if m.toast == nil { line = m.styles.Footer.Render(m.help.ShortHelpView(sh.ShortHelp())) } return m.styles.Footer.Render(fit(line, barW, 1)) } helpLine := m.styles.Footer.Render(fit(m.help.ShortHelpView(sh.ShortHelp()), barW, 1)) statusLine := m.styles.Footer.Render(fit(status, barW, 1)) return lipgloss.JoinVertical(lipgloss.Left, helpLine, statusLine) } // viewTooSmall replaces the entire UI below layout.tooSmall's threshold. func (m *Model) viewTooSmall() string { msg := fmt.Sprintf("terminal too small\n%dx%d — need at least %dx%d", m.lay.w, m.lay.h, minWidth, minHeight) box := m.styles.Modal.Render(msg) return lipgloss.Place(m.lay.w, m.lay.h, lipgloss.Center, lipgloss.Center, box, lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg))) }