glow/ui/pager.go
Carlos Alexandro Becker d89d79a00c
feat: --preserve-new-lines (#623)
closes #502
2024-07-09 09:50:10 -03:00

449 lines
10 KiB
Go

package ui
import (
"fmt"
"math"
"path/filepath"
"strings"
"time"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glow/utils"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/log"
runewidth "github.com/mattn/go-runewidth"
"github.com/muesli/reflow/ansi"
"github.com/muesli/reflow/truncate"
"github.com/muesli/termenv"
)
const (
statusBarHeight = 1
lineNumberWidth = 4
)
var (
pagerHelpHeight int
mintGreen = lipgloss.AdaptiveColor{Light: "#89F0CB", Dark: "#89F0CB"}
darkGreen = lipgloss.AdaptiveColor{Light: "#1C8760", Dark: "#1C8760"}
lineNumberFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
statusBarNoteFg = lipgloss.AdaptiveColor{Light: "#656565", Dark: "#7D7D7D"}
statusBarBg = lipgloss.AdaptiveColor{Light: "#E6E6E6", Dark: "#242424"}
statusBarScrollPosStyle = lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#949494", Dark: "#5A5A5A"}).
Background(statusBarBg).
Render
statusBarNoteStyle = lipgloss.NewStyle().
Foreground(statusBarNoteFg).
Background(statusBarBg).
Render
statusBarHelpStyle = lipgloss.NewStyle().
Foreground(statusBarNoteFg).
Background(lipgloss.AdaptiveColor{Light: "#DCDCDC", Dark: "#323232"}).
Render
statusBarMessageStyle = lipgloss.NewStyle().
Foreground(mintGreen).
Background(darkGreen).
Render
statusBarMessageScrollPosStyle = lipgloss.NewStyle().
Foreground(mintGreen).
Background(darkGreen).
Render
statusBarMessageHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#B6FFE4")).
Background(green).
Render
helpViewStyle = lipgloss.NewStyle().
Foreground(statusBarNoteFg).
Background(lipgloss.AdaptiveColor{Light: "#f2f2f2", Dark: "#1B1B1B"}).
Render
lineNumberStyle = lipgloss.NewStyle().
Foreground(lineNumberFg).
Render
)
type (
contentRenderedMsg string
)
type pagerState int
const (
pagerStateBrowse pagerState = iota
pagerStateStatusMessage
)
type pagerModel struct {
common *commonModel
viewport viewport.Model
state pagerState
showHelp bool
statusMessage string
statusMessageTimer *time.Timer
// Current document being rendered, sans-glamour rendering. We cache
// it here so we can re-render it on resize.
currentDocument markdown
}
func newPagerModel(common *commonModel) pagerModel {
// Init viewport
vp := viewport.New(0, 0)
vp.YPosition = 0
vp.HighPerformanceRendering = config.HighPerformancePager
return pagerModel{
common: common,
state: pagerStateBrowse,
viewport: vp,
}
}
func (m *pagerModel) setSize(w, h int) {
m.viewport.Width = w
m.viewport.Height = h - statusBarHeight
if m.showHelp {
if pagerHelpHeight == 0 {
pagerHelpHeight = strings.Count(m.helpView(), "\n")
}
m.viewport.Height -= (statusBarHeight + pagerHelpHeight)
}
}
func (m *pagerModel) setContent(s string) {
m.viewport.SetContent(s)
}
func (m *pagerModel) toggleHelp() {
m.showHelp = !m.showHelp
m.setSize(m.common.width, m.common.height)
if m.viewport.PastBottom() {
m.viewport.GotoBottom()
}
}
type pagerStatusMessage struct {
message string
isError bool
}
// Perform stuff that needs to happen after a successful markdown stash. Note
// that the the returned command should be sent back the through the pager
// update function.
func (m *pagerModel) showStatusMessage(msg pagerStatusMessage) tea.Cmd {
// Show a success message to the user
m.state = pagerStateStatusMessage
m.statusMessage = msg.message
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
m.statusMessageTimer = time.NewTimer(statusMessageTimeout)
return waitForStatusMessageTimeout(pagerContext, m.statusMessageTimer)
}
func (m *pagerModel) unload() {
if m.showHelp {
m.toggleHelp()
}
if m.statusMessageTimer != nil {
m.statusMessageTimer.Stop()
}
m.state = pagerStateBrowse
m.viewport.SetContent("")
m.viewport.YOffset = 0
}
func (m pagerModel) update(msg tea.Msg) (pagerModel, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", keyEsc:
if m.state != pagerStateBrowse {
m.state = pagerStateBrowse
return m, nil
}
case "home", "g":
m.viewport.GotoTop()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "end", "G":
m.viewport.GotoBottom()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case "e":
return m, openEditor(m.currentDocument.localPath)
case "c":
// Copy using OSC 52
termenv.Copy(m.currentDocument.Body)
// Copy using native system clipboard
_ = clipboard.WriteAll(m.currentDocument.Body)
cmds = append(cmds, m.showStatusMessage(pagerStatusMessage{"Copied contents", false}))
case "r":
return m, loadLocalMarkdown(&m.currentDocument)
case "?":
m.toggleHelp()
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
}
// Glow has rendered the content
case contentRenderedMsg:
m.setContent(string(msg))
if m.viewport.HighPerformanceRendering {
cmds = append(cmds, viewport.Sync(m.viewport))
}
case editMardownMsg:
return m, openEditor(msg.md.localPath)
// We've finished editing the document, potentially making changes. Let's
// retrieve the latest version of the document so that we display
// up-to-date contents.
case editorFinishedMsg:
return m, loadLocalMarkdown(&m.currentDocument)
// We've received terminal dimensions, either for the first time or
// after a resize
case tea.WindowSizeMsg:
return m, renderWithGlamour(m, m.currentDocument.Body)
case statusMessageTimeoutMsg:
m.state = pagerStateBrowse
}
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m pagerModel) View() string {
var b strings.Builder
fmt.Fprint(&b, m.viewport.View()+"\n")
// Footer
m.statusBarView(&b)
if m.showHelp {
fmt.Fprint(&b, "\n"+m.helpView())
}
return b.String()
}
func (m pagerModel) statusBarView(b *strings.Builder) {
const (
minPercent float64 = 0.0
maxPercent float64 = 1.0
percentToStringMagnitude float64 = 100.0
)
showStatusMessage := m.state == pagerStateStatusMessage
// Logo
logo := glowLogoView()
// Scroll percent
percent := math.Max(minPercent, math.Min(maxPercent, m.viewport.ScrollPercent()))
scrollPercent := fmt.Sprintf(" %3.f%% ", percent*percentToStringMagnitude)
if showStatusMessage {
scrollPercent = statusBarMessageScrollPosStyle(scrollPercent)
} else {
scrollPercent = statusBarScrollPosStyle(scrollPercent)
}
// "Help" note
var helpNote string
if showStatusMessage {
helpNote = statusBarMessageHelpStyle(" ? Help ")
} else {
helpNote = statusBarHelpStyle(" ? Help ")
}
// Note
var note string
if showStatusMessage {
note = m.statusMessage
} else {
note = m.currentDocument.Note
}
note = truncate.StringWithTail(" "+note+" ", uint(max(0,
m.common.width-
ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
)), ellipsis)
if showStatusMessage {
note = statusBarMessageStyle(note)
} else {
note = statusBarNoteStyle(note)
}
// Empty space
padding := max(0,
m.common.width-
ansi.PrintableRuneWidth(logo)-
ansi.PrintableRuneWidth(note)-
ansi.PrintableRuneWidth(scrollPercent)-
ansi.PrintableRuneWidth(helpNote),
)
emptySpace := strings.Repeat(" ", padding)
if showStatusMessage {
emptySpace = statusBarMessageStyle(emptySpace)
} else {
emptySpace = statusBarNoteStyle(emptySpace)
}
fmt.Fprintf(b, "%s%s%s%s%s",
logo,
note,
emptySpace,
scrollPercent,
helpNote,
)
}
func (m pagerModel) helpView() (s string) {
col1 := []string{
"g/home go to top",
"G/end go to bottom",
"c copy contents",
"e edit this document",
"r reload this document",
"esc back to files",
"q quit",
}
s += "\n"
s += "k/↑ up " + col1[0] + "\n"
s += "j/↓ down " + col1[1] + "\n"
s += "b/pgup page up " + col1[2] + "\n"
s += "f/pgdn page down " + col1[3] + "\n"
s += "u ½ page up " + col1[4] + "\n"
s += "d ½ page down "
if len(col1) > 5 {
s += col1[5]
}
s = indent(s, 2)
// Fill up empty cells with spaces for background coloring
if m.common.width > 0 {
lines := strings.Split(s, "\n")
for i := 0; i < len(lines); i++ {
l := runewidth.StringWidth(lines[i])
n := max(m.common.width-l, 0)
lines[i] += strings.Repeat(" ", n)
}
s = strings.Join(lines, "\n")
}
return helpViewStyle(s)
}
// COMMANDS
func renderWithGlamour(m pagerModel, md string) tea.Cmd {
return func() tea.Msg {
s, err := glamourRender(m, md)
if err != nil {
log.Error("error rendering with Glamour", "error", err)
return errMsg{err}
}
return contentRenderedMsg(s)
}
}
// This is where the magic happens.
func glamourRender(m pagerModel, markdown string) (string, error) {
trunc := lipgloss.NewStyle().MaxWidth(m.viewport.Width - lineNumberWidth).Render
if !config.GlamourEnabled {
return markdown, nil
}
isCode := !utils.IsMarkdownFile(m.currentDocument.Note)
width := max(0, min(int(m.common.cfg.GlamourMaxWidth), m.viewport.Width))
if isCode {
width = 0
}
options := []glamour.TermRendererOption{
utils.GlamourStyle(m.common.cfg.GlamourStyle, isCode),
glamour.WithWordWrap(width),
}
if m.common.cfg.PreserveNewLines {
options = append(options, glamour.WithPreservedNewLines())
}
r, err := glamour.NewTermRenderer(options...)
if err != nil {
return "", err
}
if isCode {
markdown = utils.WrapCodeBlock(markdown, filepath.Ext(m.currentDocument.Note))
}
out, err := r.Render(markdown)
if err != nil {
return "", err
}
if isCode {
out = strings.TrimSpace(out)
}
// trim lines
lines := strings.Split(out, "\n")
var content strings.Builder
for i, s := range lines {
if isCode {
content.WriteString(lineNumberStyle(fmt.Sprintf("%"+fmt.Sprint(lineNumberWidth)+"d", i+1)))
content.WriteString(trunc(s))
} else {
content.WriteString(strings.TrimSpace(s))
}
// don't add an artificial newline after the last split
if i+1 < len(lines) {
content.WriteRune('\n')
}
}
return content.String(), nil
}
type editMardownMsg struct{ md *markdown }