2020-05-19 17:20:39 +00:00
|
|
|
package ui
|
|
|
|
|
|
|
|
import (
|
2020-05-20 19:18:59 +00:00
|
|
|
"errors"
|
2020-05-19 17:20:39 +00:00
|
|
|
"fmt"
|
|
|
|
"math"
|
|
|
|
"os"
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
"github.com/charmbracelet/boba"
|
2020-05-20 19:18:59 +00:00
|
|
|
"github.com/charmbracelet/boba/textinput"
|
2020-05-19 17:20:39 +00:00
|
|
|
"github.com/charmbracelet/boba/viewport"
|
|
|
|
"github.com/charmbracelet/charm"
|
2020-05-20 19:18:59 +00:00
|
|
|
"github.com/charmbracelet/charm/ui/common"
|
2020-05-19 17:20:39 +00:00
|
|
|
"github.com/charmbracelet/glamour"
|
|
|
|
te "github.com/muesli/termenv"
|
|
|
|
)
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
const (
|
2020-05-21 15:51:22 +00:00
|
|
|
maxDocumentWidth = 120
|
|
|
|
statusBarHeight = 1
|
|
|
|
gray = "#333333"
|
|
|
|
yellowGreen = "#ECFD65"
|
|
|
|
fuschia = "#EE6FF8"
|
|
|
|
noteHeadingText = " Set Memo "
|
|
|
|
notePromptText = " > "
|
2020-05-20 19:18:59 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
noteHeading = te.String(noteHeadingText).
|
2020-05-20 20:45:09 +00:00
|
|
|
Foreground(common.Cream.Color()).
|
|
|
|
Background(common.Green.Color()).
|
|
|
|
String()
|
|
|
|
|
|
|
|
statusBarBg = common.NewColorPair("#242424", "#E6E6E6")
|
|
|
|
statusBarNoteFg = common.NewColorPair("#7D7D7D", "#656565")
|
|
|
|
statusBarScrollPosFg = common.NewColorPair("#5A5A5A", "#949494")
|
2020-05-20 19:18:59 +00:00
|
|
|
)
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
// MSG
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
type pagerErrMsg error
|
2020-05-19 17:20:39 +00:00
|
|
|
type contentRenderedMsg string
|
2020-05-20 19:18:59 +00:00
|
|
|
type noteSavedMsg *charm.Markdown
|
2020-05-19 17:20:39 +00:00
|
|
|
|
|
|
|
// MODEL
|
|
|
|
|
|
|
|
type pagerState int
|
|
|
|
|
|
|
|
const (
|
2020-05-20 19:18:59 +00:00
|
|
|
pagerStateBrowse pagerState = iota
|
2020-05-19 17:20:39 +00:00
|
|
|
pagerStateSetNote
|
|
|
|
)
|
|
|
|
|
|
|
|
type pagerModel struct {
|
|
|
|
err error
|
2020-05-20 19:18:59 +00:00
|
|
|
cc *charm.Client
|
2020-05-19 17:20:39 +00:00
|
|
|
viewport viewport.Model
|
2020-05-20 19:18:59 +00:00
|
|
|
state pagerState
|
2020-05-19 17:20:39 +00:00
|
|
|
glamourStyle string
|
|
|
|
width int
|
|
|
|
height int
|
2020-05-20 19:18:59 +00:00
|
|
|
textInput textinput.Model
|
2020-05-19 17:20:39 +00:00
|
|
|
|
|
|
|
// Current document being rendered, sans-glamour rendering. We cache
|
|
|
|
// this here so we can re-render it on resize.
|
2020-05-21 19:14:33 +00:00
|
|
|
currentDocument *markdown
|
2020-05-19 17:20:39 +00:00
|
|
|
}
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
func newPagerModel(glamourStyle string) pagerModel {
|
|
|
|
ti := textinput.NewModel()
|
|
|
|
ti.Prompt = te.String(notePromptText).
|
|
|
|
Foreground(te.ColorProfile().Color(gray)).
|
|
|
|
Background(te.ColorProfile().Color(yellowGreen)).
|
|
|
|
String()
|
|
|
|
ti.TextColor = gray
|
|
|
|
ti.BackgroundColor = yellowGreen
|
|
|
|
ti.CursorColor = fuschia
|
|
|
|
ti.CharLimit = 128 // totally arbitrary
|
|
|
|
ti.Focus()
|
|
|
|
|
|
|
|
return pagerModel{
|
|
|
|
state: pagerStateBrowse,
|
|
|
|
glamourStyle: glamourStyle,
|
|
|
|
textInput: ti,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
func (m *pagerModel) setSize(w, h int) {
|
|
|
|
m.width = w
|
|
|
|
m.height = h
|
|
|
|
m.viewport.Width = w
|
2020-05-20 19:18:59 +00:00
|
|
|
m.viewport.Height = h - statusBarHeight
|
|
|
|
m.textInput.Width = w - len(noteHeadingText) - len(notePromptText) - 1
|
2020-05-19 17:20:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (m *pagerModel) setContent(s string) {
|
|
|
|
m.viewport.SetContent(s)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (m *pagerModel) unload() {
|
2020-05-20 19:18:59 +00:00
|
|
|
m.state = pagerStateBrowse
|
2020-05-19 17:20:39 +00:00
|
|
|
m.viewport.SetContent("")
|
|
|
|
m.viewport.Y = 0
|
2020-05-20 19:18:59 +00:00
|
|
|
m.textInput.Reset()
|
2020-05-19 17:20:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// UPDATE
|
|
|
|
|
|
|
|
func pagerUpdate(msg boba.Msg, m pagerModel) (pagerModel, boba.Cmd) {
|
2020-05-20 19:18:59 +00:00
|
|
|
var (
|
|
|
|
cmd boba.Cmd
|
|
|
|
cmds []boba.Cmd
|
|
|
|
)
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
case boba.KeyMsg:
|
|
|
|
switch m.state {
|
|
|
|
case pagerStateSetNote:
|
|
|
|
switch msg.String() {
|
|
|
|
case "q":
|
|
|
|
fallthrough
|
|
|
|
case "esc":
|
|
|
|
m.state = pagerStateBrowse
|
|
|
|
return m, nil
|
|
|
|
case "enter":
|
2020-05-20 20:27:19 +00:00
|
|
|
var cmd boba.Cmd
|
|
|
|
if m.textInput.Value() != m.currentDocument.Note { // don't update if the note didn't change
|
|
|
|
m.currentDocument.Note = m.textInput.Value() // update optimistically
|
|
|
|
cmd = saveDocumentNote(m.cc, m.currentDocument.ID, m.currentDocument.Note)
|
|
|
|
}
|
2020-05-20 19:18:59 +00:00
|
|
|
m.state = pagerStateBrowse
|
|
|
|
m.textInput.Reset()
|
2020-05-20 20:27:19 +00:00
|
|
|
return m, cmd
|
2020-05-20 19:18:59 +00:00
|
|
|
}
|
|
|
|
default:
|
|
|
|
switch msg.String() {
|
|
|
|
case "q":
|
|
|
|
fallthrough
|
|
|
|
case "esc":
|
|
|
|
if m.state != pagerStateBrowse {
|
|
|
|
m.state = pagerStateBrowse
|
|
|
|
return m, nil
|
|
|
|
}
|
|
|
|
case "n":
|
2020-05-21 19:14:33 +00:00
|
|
|
// Users can't set the note on news markdown
|
|
|
|
if m.currentDocument.markdownType == newsMarkdown {
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
m.state = pagerStateSetNote
|
2020-05-20 20:17:18 +00:00
|
|
|
if m.textInput.Value() == "" {
|
|
|
|
// Pre-populate with existing value
|
|
|
|
m.textInput.SetValue(m.currentDocument.Note)
|
|
|
|
m.textInput.CursorEnd()
|
|
|
|
}
|
2020-05-20 19:18:59 +00:00
|
|
|
return m, textinput.Blink(m.textInput)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
case pagerErrMsg:
|
|
|
|
m.err = msg
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
// Glow has rendered the content
|
|
|
|
case contentRenderedMsg:
|
|
|
|
m.setContent(string(msg))
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
// We've reveived terminal dimensions, either for the first time or
|
|
|
|
// after a resize
|
|
|
|
case terminalSizeMsg:
|
|
|
|
if msg.Error() != nil {
|
|
|
|
m.err = msg.Error()
|
|
|
|
return m, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var cmd boba.Cmd
|
|
|
|
if m.currentDocument != nil {
|
|
|
|
cmd = renderWithGlamour(m, m.currentDocument.Body)
|
|
|
|
}
|
|
|
|
return m, cmd
|
|
|
|
}
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
switch m.state {
|
|
|
|
case pagerStateSetNote:
|
|
|
|
m.textInput, cmd = textinput.Update(msg, m.textInput)
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
default:
|
|
|
|
m.viewport, cmd = viewport.Update(msg, m.viewport)
|
|
|
|
cmds = append(cmds, cmd)
|
|
|
|
}
|
2020-05-19 17:20:39 +00:00
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
return m, boba.Batch(cmds...)
|
2020-05-19 17:20:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// VIEW
|
|
|
|
|
|
|
|
func pagerView(m pagerModel) string {
|
2020-05-20 19:18:59 +00:00
|
|
|
var footer string
|
|
|
|
if m.state == pagerStateSetNote {
|
|
|
|
footer = pagerSetNoteView(m)
|
|
|
|
} else {
|
|
|
|
footer = pagerStatusBarView(m)
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
return fmt.Sprintf(
|
|
|
|
"\n%s\n%s",
|
|
|
|
viewport.View(m.viewport),
|
2020-05-20 19:18:59 +00:00
|
|
|
footer,
|
2020-05-19 17:20:39 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
func pagerStatusBarView(m pagerModel) string {
|
|
|
|
// Logo
|
|
|
|
logoText := " Glow "
|
|
|
|
logo := glowLogoView(logoText)
|
|
|
|
|
|
|
|
// Scroll percent
|
|
|
|
scrollPercent := math.Max(0.0, math.Min(1.0, m.viewport.ScrollPercent()))
|
|
|
|
percentText := fmt.Sprintf(" %3.f%% ", scrollPercent*100)
|
|
|
|
percent := te.String(percentText).
|
2020-05-20 20:45:09 +00:00
|
|
|
Foreground(statusBarScrollPosFg.Color()).
|
2020-05-19 17:20:39 +00:00
|
|
|
Background(statusBarBg.Color()).
|
|
|
|
String()
|
|
|
|
|
|
|
|
// Note
|
|
|
|
noteText := m.currentDocument.Note
|
|
|
|
if len(noteText) == 0 {
|
|
|
|
noteText = "(No title)"
|
|
|
|
}
|
|
|
|
noteText = truncate(" "+noteText+" ", max(0, m.width-len(logoText)-len(percentText)))
|
|
|
|
note := te.String(noteText).
|
2020-05-20 20:45:09 +00:00
|
|
|
Foreground(statusBarNoteFg.Color()).
|
2020-05-19 17:20:39 +00:00
|
|
|
Background(statusBarBg.Color()).String()
|
|
|
|
|
|
|
|
// Empty space
|
|
|
|
emptyCell := te.String(" ").Background(statusBarBg.Color()).String()
|
|
|
|
padding := max(0, m.width-len(logoText)-len(noteText)-len(percentText))
|
|
|
|
emptySpace := strings.Repeat(emptyCell, padding)
|
|
|
|
|
|
|
|
return logo + note + emptySpace + percent
|
|
|
|
}
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
func pagerSetNoteView(m pagerModel) string {
|
|
|
|
return noteHeading + textinput.View(m.textInput)
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
// CMD
|
|
|
|
|
|
|
|
func renderWithGlamour(m pagerModel, md string) boba.Cmd {
|
|
|
|
return func() boba.Msg {
|
|
|
|
s, err := glamourRender(m, md)
|
|
|
|
if err != nil {
|
|
|
|
return errMsg(err)
|
|
|
|
}
|
|
|
|
return contentRenderedMsg(s)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-20 19:18:59 +00:00
|
|
|
func saveDocumentNote(cc *charm.Client, id int, note string) boba.Cmd {
|
|
|
|
if cc == nil {
|
|
|
|
return func() boba.Msg {
|
|
|
|
return pagerErrMsg(errors.New("can't set note; no charm client"))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return func() boba.Msg {
|
|
|
|
if err := cc.SetMarkdownNote(id, note); err != nil {
|
|
|
|
return pagerErrMsg(err)
|
|
|
|
}
|
|
|
|
return noteSavedMsg(&charm.Markdown{ID: id, Note: note})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:20:39 +00:00
|
|
|
// This is where the magic happens
|
|
|
|
func glamourRender(m pagerModel, markdown string) (string, error) {
|
|
|
|
|
|
|
|
if os.Getenv("GLOW_DISABLE_GLAMOUR") != "" {
|
|
|
|
return markdown, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// initialize glamour
|
|
|
|
var gs glamour.TermRendererOption
|
|
|
|
if m.glamourStyle == "auto" {
|
|
|
|
gs = glamour.WithAutoStyle()
|
|
|
|
} else {
|
|
|
|
gs = glamour.WithStylePath(m.glamourStyle)
|
|
|
|
}
|
|
|
|
|
2020-05-21 15:51:22 +00:00
|
|
|
width := max(0, min(maxDocumentWidth, m.viewport.Width))
|
2020-05-19 17:20:39 +00:00
|
|
|
r, err := glamour.NewTermRenderer(
|
|
|
|
gs,
|
2020-05-20 20:54:37 +00:00
|
|
|
glamour.WithWordWrap(width),
|
2020-05-19 17:20:39 +00:00
|
|
|
)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
out, err := r.Render(markdown)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
|
|
|
|
// trim lines
|
|
|
|
lines := strings.Split(string(out), "\n")
|
|
|
|
|
|
|
|
var content string
|
|
|
|
for i, s := range lines {
|
|
|
|
content += strings.TrimSpace(s)
|
|
|
|
|
|
|
|
// don't add an artificial newline after the last split
|
|
|
|
if i+1 < len(lines) {
|
|
|
|
content += "\n"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return content, nil
|
|
|
|
}
|