package main // The frame every panel is drawn in, and the small pieces that sit inside one. // // A frame is drawn by hand rather than with lipgloss borders, because the title // and the tab names live inside the top border and must line up with the body // exactly, whatever the width. import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" ) type panel struct { title string tabs []string // empty when the panel has one view active int focused bool width int height int } func (p panel) bodyHeight() int { return max(1, p.height-2) } func (p panel) bodyWidth() int { return max(1, p.width-2) } // render wraps already-styled lines in the frame. func (p panel) render(lines []string) string { bw, bh := p.bodyWidth(), p.bodyHeight() border := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Surface)) mark := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)) if p.focused { border = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)) mark = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true) } // Top edge: the title, then the tabs, then the focus mark on the right. // When there is not enough room, the focus mark goes first and the title is // cut second, because a missing word reads better than a broken frame. title := " " + p.title + " " tabs := "" if len(p.tabs) > 1 { var b strings.Builder for i, t := range p.tabs { if i > 0 { b.WriteString(" ") } if i == p.active { b.WriteString(tabActiveStyle().Render(" " + t + " ")) } else { b.WriteString(tabStyle().Render(" " + t + " ")) } } tabs = " " + b.String() } right := "" if p.focused { right = mark.Render("·focus· ") } // Two corners and a lead-in take three columns. avail := p.width - 3 if lipgloss.Width(title)+lipgloss.Width(tabs)+lipgloss.Width(right) > avail { right = "" } if w := lipgloss.Width(title) + lipgloss.Width(tabs); w > avail { if aw := lipgloss.Width(tabs); aw < avail { title = truncateVisible(title, avail-aw) } else { title = "" tabs = truncateVisible(tabs, avail) } } used := lipgloss.Width(title) + lipgloss.Width(tabs) + lipgloss.Width(right) fill := max(0, avail-used) top := border.Render("╭─") + panelTitleStyle().Render(title) + tabs + border.Render(strings.Repeat("─", fill)) + right + border.Render("╮") // Body: every line exactly the inner width, no exceptions. Content is // inset by one column so text never touches the border. out := make([]string, 0, p.height) out = append(out, top) side := border.Render("│") for i := 0; i < bh; i++ { line := "" if i < len(lines) { line = lines[i] } out = append(out, side+" "+padTo(line, bw-1)+side) } out = append(out, border.Render("╰"+strings.Repeat("─", p.width-2)+"╯")) return strings.Join(out, "\n") } // padTo makes a line exactly w columns wide, cutting it if it is too long. // Widths are measured on the visible text, so styled strings work. func padTo(s string, w int) string { visible := lipgloss.Width(s) if visible > w { s = truncateVisible(s, w) visible = lipgloss.Width(s) } if visible < w { return s + strings.Repeat(" ", w-visible) } return s } // truncateVisible cuts a styled string to w columns, keeping the styling of the // runes it keeps. It works rune by rune because a byte slice would split an // escape sequence and break the terminal. func truncateVisible(s string, w int) string { var out strings.Builder visible, i := 0, 0 for i < len(s) && visible < w-1 { if s[i] == 0x1b { // copy the whole escape sequence j := i + 1 if j < len(s) && s[j] == '[' { j++ for j < len(s) && (s[j] < '@' || s[j] > '~') { j++ } if j < len(s) { j++ } } out.WriteString(s[i:j]) i = j continue } r := []rune(s[i:]) out.WriteRune(r[0]) i += len(string(r[0])) visible++ } if i < len(s) { out.WriteString("…") out.WriteString("\x1b[0m") } return out.String() } func panelTitleStyle() lipgloss.Style { return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Text)) } func tabStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)) } func tabActiveStyle() lipgloss.Style { return lipgloss.NewStyle(). Foreground(lipgloss.Color(active.Base)). Background(lipgloss.Color(active.Accent)). Bold(true) } func mutedStyle2() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)) } func dimStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(active.Overlay)) } func selectedStyle2() lipgloss.Style { return lipgloss.NewStyle(). Foreground(lipgloss.Color(active.Base)). Background(lipgloss.Color(active.Accent)). Bold(true) } // statusPill is a state in two letters of shape and one of colour, so it still // reads in a monochrome terminal. func statusPill(status string) string { label, colour := statusLabel(status) return lipgloss.NewStyle(). Foreground(lipgloss.Color(colour)). Render(label) } func statusLabel(status string) (string, string) { switch status { case "in_progress": return icons.Running + " running ", active.Info case "blocked": return icons.Blocked + " blocked ", active.Error case "in_review": return icons.Review + " review ", active.Warning case "done": return icons.Done + " done ", active.Success case "cancelled": return icons.Cancelled + " cancel ", active.Overlay case "backlog": return icons.Todo + " backlog ", active.Muted default: return icons.Todo + " todo ", active.Text } } func badge(text, colour string) string { return lipgloss.NewStyle(). Foreground(lipgloss.Color(colour)). Render("[" + text + "]") } func keyHint(keys, what string) string { k := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).Render(keys) w := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted)).Render(what) return k + " " + w } // hintLine joins key hints with a comma, because a footer is a list of keys and // not a sentence. func hintLine(hints []string, width int) string { joined := strings.Join(hints, dimStyle().Render(", ")) if lipgloss.Width(joined) > width { return truncateVisible(joined, width) } return joined } // stack joins rendered blocks vertically, padding each to the same width so a // short block does not leave the frame ragged. func stack(width int, blocks ...string) []string { var out []string for _, b := range blocks { for _, ln := range strings.Split(b, "\n") { out = append(out, padTo(ln, width)) } } return out } func fmtCount(n int, one, many string) string { if n == 1 { return fmt.Sprintf("%d %s", n, one) } return fmt.Sprintf("%d %s", n, many) } // wrap breaks one paragraph into lines no wider than w, on word boundaries. // It is used for anything a person wrote, so it never cuts a word in half. func wrap(s string, w int) []string { if w < 4 { w = 4 } if s == "" { return []string{""} } var lines []string cur := "" for _, word := range strings.Fields(s) { switch { case cur == "": cur = word case len([]rune(cur))+1+len([]rune(word)) <= w: cur += " " + word default: lines = append(lines, cur) cur = word } } if cur != "" { lines = append(lines, cur) } if len(lines) == 0 { return []string{""} } return lines } // helpOverlay draws the keys for the panel that asked for it, centred over the // screen. It replaces the whole view rather than floating over it, so nothing // behind it can be misread as still active. func helpOverlay(title string, entries [][2]string, w, h int) string { boxW := min(64, max(30, w-8)) boxH := min(len(entries)+4, max(8, h-4)) lines := []string{""} for _, e := range entries { key := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true). Width(16).Render(e[0]) lines = append(lines, " "+key+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(e[1])) } lines = append(lines, "", dimStyle().Render(" esc or ? closes this")) p := panel{title: title, width: boxW, height: boxH, focused: true} return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, p.render(lines)) } // short is the first eight characters of an identifier: enough to recognise a // run or a task in a column, never enough to confuse two of them on one screen. func short(id string) string { if len(id) <= 8 { return id } return id[:8] }