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) }