package ui
import (
"encoding/base64"
"fmt"
"io"
"os"
"regexp"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
// spinnerFrames is the classic braille cycle. It reads as motion even in a
// terminal whose font lacks the box-drawing glyphs, because the dots move.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// spinnerTickMsg advances the spinner.
type spinnerTickMsg struct{}
// spinnerTick schedules the next frame. 80ms is fast enough to look alive and
// slow enough not to cost anything.
func spinnerTick() tea.Cmd {
return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg {
return spinnerTickMsg{}
})
}
// loadingModal is a waiting state that says what it is waiting for.
//
// A spinner with no label is a lie: it implies progress that may not exist.
type loadingModal struct {
title string
detail string
frame int
until time.Time
}
func newLoading(title, detail string) *loadingModal {
return &loadingModal{title: title, detail: detail}
}
func (l *loadingModal) Update(msg tea.Msg) (modal, tea.Cmd) {
switch msg.(type) {
case spinnerTickMsg:
// advanced by the model, which owns the tick chain
l.frame++
return l, nil
case tea.KeyMsg:
// waiting is not cancellable yet; esc must not pretend otherwise
return l, nil
}
return l, nil
}
func (l *loadingModal) View(width, height int) string {
spin := Accent.Render(spinnerFrames[l.frame%len(spinnerFrames)])
body := Title.Render(l.title) + "\n\n" +
" " + spin + " " + Text.Render(l.detail)
return place(Box.Render(body), width, height)
}
// textModal is a scrollable block of pre-styled lines.
//
// An advisory, a full audit run, a package's long-form detail: anything that
// would otherwise mean opening a link and reading it elsewhere.
type textModal struct {
title string
lines []string
scroll int
loading bool
detail string
footer string
frame int
//: incremental search. A command queue runs to hundreds of lines and the
//: question is usually "does this touch /etc", not "read all of it".
//: whether the view tracks the end as new lines arrive. Scrolling up stops
//: it -- someone reading back does not want the page pulled away -- and G
//: starts it again.
follow bool
//: set once the view has been shown, so "at the end" can be judged
rows int
//: colour the lines by how alarming they look, and offer to copy them
logMode bool
searching bool
query string
matches []int
matchAt int
searchErr string
}
// logLine colours a line by how alarming it looks.
//
// A build's output is mostly routine, and the two lines that matter are the
// ones that say something went wrong. Reading a wall of undifferentiated grey
// to find them is the reason log viewers colour at all.
func logLine(line string) string {
plain := ansi.Strip(line)
switch {
case errorLine.MatchString(plain):
return Err.Render(line)
case warnLine.MatchString(plain):
return Warn.Render(line)
case strings.HasPrefix(strings.TrimSpace(plain), "==>"):
// makepkg's own headings: structure, not a problem
return Title.Render(line)
default:
return Muted.Render(line)
}
}
var (
errorLine = regexp.MustCompile(
`(?i)\b(error|failed|failure|fatal|cannot|panic|abort|denied|refused|no such file)\b`)
warnLine = regexp.MustCompile(
`(?i)\b(warning|warn|deprecated|skipped|missing|conflict)\b`)
)
// copy puts the whole view on the clipboard.
//
// OSC 52 rather than a helper program: it travels over SSH, needs nothing
// installed, and cannot fail because a tool is absent. Terminals cap how much
// they will accept, so this is bounded and says when it truncated.
// clipboard is where a copy is written. A variable so tests do not spray
// ninety kilobytes of base64 across the test runner's output.
var clipboard io.Writer = os.Stdout
func (t *textModal) copy() tea.Cmd {
const limit = 96 * 1024
text := strings.Join(t.lines, "\n")
truncated := false
if len(text) > limit {
text, truncated = text[:limit], true
}
return func() tea.Msg {
fmt.Fprint(clipboard, osc52(text))
if truncated {
return copiedMsg{what: "copied (truncated; full log on disk)"}
}
return copiedMsg{what: fmt.Sprintf("copied %d lines", len(t.lines))}
}
}
// osc52 wraps text in the terminal's clipboard sequence.
func osc52(text string) string {
return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a"
}
// newText opens at the top. A log is the exception and says so by asking to
// follow -- help that opened scrolled to its last line is help nobody reads.
func newText(title string) *textModal {
return &textModal{title: title}
}
func newTextLoading(title, detail string) *textModal {
return &textModal{title: title, loading: true, detail: detail}
}
// textBodyMsg delivers the lines once a fetch finishes.
type textBodyMsg struct {
title string
lines []string
foot string
err error
}
func (t *textModal) Update(msg tea.Msg) (modal, tea.Cmd) {
switch msg := msg.(type) {
case spinnerTickMsg:
// advanced by the model, which owns the tick chain
t.frame++
return t, nil
case textBodyMsg:
if msg.title != t.title {
return t, nil // a stale fetch for a modal we have moved on from
}
t.loading = false
if msg.err != nil {
t.lines = []string{Err.Render("could not load this"), "",
Muted.Render(msg.err.Error())}
return t, nil
}
t.lines, t.footer = msg.lines, msg.foot
return t, nil
case tea.KeyMsg:
if t.searching {
return t.updateSearch(msg)
}
switch msg.String() {
case "q", "esc", "ctrl+c":
return nil, nil
case "/":
t.searching = true
t.query = ""
t.searchErr = ""
case "n":
t.nextMatch(1)
case "N":
t.nextMatch(-1)
case "j", "down":
t.scroll++
case "k", "up":
t.follow = false
t.scroll--
case "ctrl+d", "pgdown":
t.scroll += 10
case "ctrl+u", "pgup":
t.follow = false
t.scroll -= 10
case "g", "home":
t.follow = false
t.scroll = 0
case "G", "end":
t.follow = true
t.scroll = 1 << 30
case "f":
// resume following the end, for a view that was scrolled away
t.follow = !t.follow
if t.follow {
t.scroll = 1 << 30
}
case "c":
return t, t.copy()
}
}
return t, nil
}
// updateSearch handles keys while the query is being typed.
func (t *textModal) updateSearch(msg tea.KeyMsg) (modal, tea.Cmd) {
switch msg.Type {
case tea.KeyEsc:
t.searching = false
t.query = ""
t.matches = nil
t.searchErr = ""
return t, nil
case tea.KeyEnter:
t.searching = false
if t.query != "" && len(t.matches) == 0 {
t.searchErr = "no match"
}
// Asking for something and being carried past it is not an answer:
// a committed search stops following, so the hit stays on screen.
if t.query != "" {
t.follow = false
} else {
t.follow = true
}
return t, nil
case tea.KeyBackspace:
if t.query != "" {
runes := []rune(t.query)
t.query = string(runes[:len(runes)-1])
}
t.recompute()
return t, nil
case tea.KeyRunes, tea.KeySpace:
t.query += string(msg.Runes)
if msg.Type == tea.KeySpace {
t.query += " "
}
t.recompute()
return t, nil
}
return t, nil
}
// recompute refreshes the match list and jumps to the first hit.
func (t *textModal) recompute() {
t.searchErr = ""
t.matches = t.matches[:0]
if t.query == "" {
return
}
needle := strings.ToLower(t.query)
for i, line := range t.lines {
if strings.Contains(strings.ToLower(ansi.Strip(line)), needle) {
t.matches = append(t.matches, i)
}
}
t.matchAt = 0
if len(t.matches) > 0 {
t.scroll = t.matches[0]
}
}
func (t *textModal) nextMatch(step int) {
if len(t.matches) == 0 {
return
}
t.matchAt = (t.matchAt + step + len(t.matches)) % len(t.matches)
t.scroll = t.matches[t.matchAt]
}
func (t *textModal) View(width, height int) string {
inner := width - 8
if inner < 20 {
inner = 20
}
rows := height - 10
if rows < 3 {
rows = 3
}
if t.loading {
spin := Accent.Render(spinnerFrames[t.frame%len(spinnerFrames)])
body := Title.Render(t.title) + "\n\n" + " " + spin + " " + Text.Render(t.detail)
return place(Box.Render(body), width, height)
}
var b strings.Builder
b.WriteString(Title.Render(t.title))
b.WriteString("\n\n")
total := len(t.lines)
// The reader's place is theirs unless they are following the end, in which
// case new lines carry the view with them.
t.rows = rows
if t.scroll > total-rows {
t.scroll = total - rows
}
if t.scroll < 0 {
t.scroll = 0
}
// Following applies only to an unfiltered view. With a query live the
// reader is looking at a result, and this would drag them back to the end
// on every render -- which is what made a search report "3 matches" while
// showing the wrong part of the log.
if t.follow && !t.searching && t.query == "" {
t.scroll = max(0, total-rows)
}
end := min(total, t.scroll+rows)
for _, line := range t.lines[t.scroll:end] {
if t.logMode {
line = logLine(line)
}
b.WriteString(ansi.Truncate(line, inner, "…") + "\n")
}
for i := end - t.scroll; i < rows; i++ {
b.WriteString("\n")
}
pos := fmt.Sprintf("[%d/%d]", t.scroll+1, max(1, total))
// Built from what the view actually offers, rather than the same three
// keys written out in four places with different accuracy.
keys := []string{"[j/k] scroll", "[/] search"}
if !t.follow {
keys = append([]string{"[f] follow"}, keys...)
} else {
keys = append(keys, "[f] unfollow")
}
if t.logMode {
keys = append(keys, "[c] copy")
}
keys = append(keys, "[esc] close")
hint := strings.Join(keys, " • ")
if len(t.matches) > 0 {
hint = fmt.Sprintf("[n/N] %d/%d • ", t.matchAt+1, len(t.matches)) + hint
} else if t.searchErr != "" {
hint = Warn.Render(t.searchErr) + " • " + hint
}
// The position is right-aligned and must survive: a long hint (a full log
// path, say) would otherwise push it off the end.
room := inner - len(pos) - 2
if room < 8 {
room = 8
}
hint = ansi.Truncate(hint, room, "…")
gap := max(1, inner-ansi.StringWidth(hint)-len(pos))
b.WriteString("\n" + Desc.Render(hint) + strings.Repeat(" ", gap) + Muted.Render(pos))
if t.searching || t.query != "" {
cursor := ""
if t.searching {
cursor = Accent.Render("_")
}
label := Muted.Render("/") + Text.Render(t.query) + cursor
if hits := len(t.matches); hits > 0 {
// Was glamour markup, which lipgloss renders literally -- the
// footer read "{underline}2 match(es){/}".
label += " " + Accent.Render(fmt.Sprintf("%d match(es)", hits))
}
b.WriteString("\n" + ansi.Truncate(label, inner, "…"))
}
return place(Box.Render(b.String()), width, height)
}