Board workspace with real panels, tabs and theme; Nerd Font declared for all three machines
This commit is contained in:
241
tui/frame.go
Normal file
241
tui/frame.go
Normal file
@@ -0,0 +1,241 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user