242 lines
6.4 KiB
Go
242 lines
6.4 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.
|
|
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)+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 "▶ running ", active.Info
|
|
case "blocked":
|
|
return "■ blocked ", active.Error
|
|
case "in_review":
|
|
return "◆ review ", active.Warning
|
|
case "done":
|
|
return "✓ done ", active.Success
|
|
case "cancelled":
|
|
return "✕ cancel ", active.Overlay
|
|
case "backlog":
|
|
return "· backlog ", active.Muted
|
|
default:
|
|
return "· 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)
|
|
}
|