mirror of
https://github.com/charmbracelet/glow
synced 2024-12-14 06:02:27 +00:00
d89d79a00c
closes #502
449 lines
10 KiB
Go
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 }
|