Files
paperclip-harness/tui/frame.go

317 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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
}
// 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))
}
// singlePanel draws one panel filling the whole pane: a title, optional tabs,
// a scrolling body, and one line of keys. It is what a pane shows when a
// workspace has been split into real panes instead of one program drawing
// several panels inside itself.
func singlePanel(title string, tabs []string, active int, body []string, w, h int, footer string, scroll int) string {
if w < 12 || h < 4 {
return fmt.Sprintf("this pane is %d × %d; it needs 12 × 4", w, h)
}
p := panel{title: title, tabs: tabs, active: active, width: w, height: h - 1, focused: true}
visible := p.bodyHeight()
maxScroll := max(0, len(body)-visible)
if scroll > maxScroll {
scroll = maxScroll
}
if scroll < 0 {
scroll = 0
}
from := scroll
to := min(len(body), from+visible)
if to < from {
to = from
}
return p.render(body[from:to]) + "\n" + truncateVisible(footer, w)
}