Files
paperclip-harness/tui/frame.go

274 lines
7.2 KiB
Go

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++
}
out.WriteString("…")
// close any styling that is still open
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 dot, dropping the ones that do not apply.
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
}