317 lines
8.9 KiB
Go
317 lines
8.9 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++
|
||
}
|
||
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)
|
||
}
|