386 lines
8.6 KiB
Go
386 lines
8.6 KiB
Go
/*
|
|
this pakckage basically handles notes which are the individual files for spaced
|
|
repetition in mend
|
|
*/
|
|
|
|
package note
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textarea"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/glamour"
|
|
glStyles "github.com/charmbracelet/glamour/styles"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/ast"
|
|
"github.com/yuin/goldmark/text"
|
|
)
|
|
|
|
type Section struct {
|
|
Title string
|
|
Content string
|
|
Hints []string
|
|
}
|
|
|
|
type ViewState int
|
|
|
|
const (
|
|
StateTitleOnly ViewState = iota
|
|
StateContent
|
|
StateHints
|
|
)
|
|
|
|
type NoteView struct {
|
|
// the actual content
|
|
Path string
|
|
rawContent string // full content for editing
|
|
sections []Section
|
|
currentSectionIndex int
|
|
// display layer
|
|
err error
|
|
loading bool
|
|
vp viewport.Model
|
|
mdRenderer *glamour.TermRenderer
|
|
viewState ViewState
|
|
// editing
|
|
textarea textarea.Model
|
|
isEditing bool
|
|
}
|
|
|
|
// ================== messages ===================
|
|
type LoadNoteMsg struct {
|
|
Path string
|
|
Force bool
|
|
}
|
|
|
|
type LoadedNote struct {
|
|
RawContent string
|
|
Sections []Section
|
|
Err error
|
|
}
|
|
|
|
func newMdRenderer() *glamour.TermRenderer {
|
|
// styling in glamour can be better, I would rather have a fluent style api here
|
|
// https://github.com/charmbracelet/glamour/issues/294
|
|
mdStyleConfig := glStyles.TokyoNightStyleConfig
|
|
var margin uint = 2
|
|
mdStyleConfig.Document.Margin = &margin
|
|
mdStyleConfig.Document.BlockPrefix = ""
|
|
mdStyleConfig.Document.BlockSuffix = ""
|
|
mdRenderer, _ := glamour.NewTermRenderer(
|
|
glamour.WithStyles(mdStyleConfig),
|
|
glamour.WithWordWrap(80),
|
|
)
|
|
return mdRenderer
|
|
}
|
|
|
|
func newTextArea() textarea.Model {
|
|
ta := textarea.New()
|
|
ta.Focus()
|
|
ta.Prompt = ""
|
|
ta.ShowLineNumbers = false
|
|
return ta
|
|
}
|
|
|
|
func NewNoteView() *NoteView {
|
|
return &NoteView{
|
|
loading: false,
|
|
mdRenderer: newMdRenderer(),
|
|
vp: viewport.New(0, 0),
|
|
viewState: StateTitleOnly,
|
|
textarea: newTextArea(),
|
|
}
|
|
}
|
|
|
|
func (m *NoteView) Init() tea.Cmd {
|
|
return textarea.Blink
|
|
}
|
|
|
|
func (m *NoteView) IsEditing() bool {
|
|
return m.isEditing
|
|
}
|
|
|
|
func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.vp.Width = msg.Width
|
|
m.vp.Height = msg.Height - 1
|
|
m.textarea.SetWidth(msg.Width)
|
|
m.textarea.SetHeight(msg.Height)
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
if m.isEditing {
|
|
switch msg.String() {
|
|
case "esc", "ctrl+q":
|
|
m.isEditing = false
|
|
return m, saveContent(m.Path, m.textarea.Value())
|
|
}
|
|
var cmd tea.Cmd
|
|
m.textarea, cmd = m.textarea.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "enter":
|
|
if m.Path != "" && !m.loading {
|
|
m.isEditing = true
|
|
m.textarea.SetValue(m.rawContent)
|
|
m.textarea.Focus()
|
|
return m, textarea.Blink
|
|
}
|
|
case " ":
|
|
switch m.viewState {
|
|
case StateTitleOnly:
|
|
m.viewState = StateHints
|
|
case StateHints:
|
|
m.viewState = StateContent
|
|
case StateContent:
|
|
m.viewState = StateTitleOnly
|
|
}
|
|
m.vp.SetContent(m.renderNote())
|
|
case "pgup":
|
|
m.vp.PageUp()
|
|
return m, nil
|
|
case "pgdown":
|
|
m.vp.PageDown()
|
|
return m, nil
|
|
case "left", "a":
|
|
if m.currentSectionIndex > 0 {
|
|
m.currentSectionIndex--
|
|
m.vp.SetContent(m.renderNote())
|
|
m.vp.GotoTop()
|
|
}
|
|
return m, nil
|
|
case "right", "d":
|
|
if m.currentSectionIndex < len(m.sections)-1 {
|
|
m.currentSectionIndex++
|
|
m.vp.SetContent(m.renderNote())
|
|
m.vp.GotoTop()
|
|
}
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.vp, cmd = m.vp.Update(msg)
|
|
return m, cmd
|
|
|
|
case LoadNoteMsg:
|
|
if m.Path == msg.Path && !msg.Force {
|
|
return m, nil //noop
|
|
}
|
|
m.Path = msg.Path
|
|
m.isEditing = false
|
|
m.loading = true
|
|
m.currentSectionIndex = 0
|
|
return m, fetchContent(msg.Path)
|
|
|
|
case LoadedNote:
|
|
m.loading = false
|
|
m.rawContent = msg.RawContent
|
|
m.sections = msg.Sections
|
|
m.err = msg.Err
|
|
m.currentSectionIndex = 0
|
|
m.viewState = StateTitleOnly
|
|
m.vp.SetContent(m.renderNote())
|
|
|
|
return m, nil
|
|
|
|
case tea.MouseMsg:
|
|
if m.isEditing {
|
|
return m, nil // or handle mouse in textarea if supported
|
|
}
|
|
var cmd tea.Cmd
|
|
m.vp, cmd = m.vp.Update(msg)
|
|
return m, cmd
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m NoteView) View() string {
|
|
if m.loading {
|
|
return "loading..."
|
|
}
|
|
if m.Path == "" {
|
|
return ""
|
|
}
|
|
|
|
if m.err != nil {
|
|
return "Error: " + m.err.Error()
|
|
}
|
|
|
|
if m.isEditing {
|
|
return m.textarea.View()
|
|
}
|
|
|
|
page := m.currentSectionIndex + 1
|
|
total := len(m.sections)
|
|
var footer string
|
|
if total == 0 {
|
|
footer = "No sections"
|
|
} else {
|
|
footer = fmt.Sprintf("%d/%d", page, total)
|
|
}
|
|
footer = lipgloss.NewStyle().Width(m.vp.Width).Align(lipgloss.Right).Render(footer)
|
|
|
|
return m.vp.View() + "\n" + footer
|
|
}
|
|
|
|
func fetchContent(path string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return LoadedNote{Err: err}
|
|
}
|
|
|
|
rawContent := string(data)
|
|
sections := ParseSections(data)
|
|
|
|
return LoadedNote{
|
|
RawContent: rawContent,
|
|
Sections: sections,
|
|
}
|
|
}
|
|
}
|
|
|
|
func ParseSections(source []byte) []Section {
|
|
md := goldmark.New()
|
|
reader := text.NewReader(source)
|
|
doc := md.Parser().Parse(reader)
|
|
|
|
title := "no title"
|
|
sections := make([]Section, 0)
|
|
lastPos := 0
|
|
|
|
for child := doc.FirstChild(); child != nil; child = child.NextSibling() {
|
|
if child.Kind() == ast.KindHeading {
|
|
if child.Lines().Len() <= 0 {
|
|
continue
|
|
}
|
|
|
|
level := child.(*ast.Heading).Level
|
|
headingStart := child.Lines().At(0).Start - level - 1
|
|
headingEnd := child.Lines().At(child.Lines().Len() - 1).Stop
|
|
|
|
contentEnd := headingStart
|
|
if lastPos < contentEnd {
|
|
// you have a heading and a content to accumulte over
|
|
contentsRaw := source[lastPos:contentEnd]
|
|
contents := strings.TrimSpace(string(contentsRaw))
|
|
hints := ExtractHints(contents)
|
|
sections = append(sections, Section{
|
|
Title: title,
|
|
Content: contents,
|
|
Hints: hints,
|
|
})
|
|
lastPos = headingEnd
|
|
}
|
|
// for the next heading
|
|
title = strings.TrimSpace(string(source[headingStart:headingEnd]))
|
|
lastPos = headingEnd
|
|
}
|
|
}
|
|
// last section
|
|
if lastPos < len(source) {
|
|
contentsRaw := source[lastPos:]
|
|
contents := strings.TrimSpace(string(contentsRaw))
|
|
hints := ExtractHints(contents)
|
|
sections = append(sections, Section{
|
|
Title: title,
|
|
Content: contents,
|
|
Hints: hints,
|
|
})
|
|
}
|
|
|
|
return sections
|
|
}
|
|
|
|
func saveContent(path, content string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := os.WriteFile(path, []byte(content), 0644)
|
|
if err != nil {
|
|
return LoadedNote{Err: err}
|
|
}
|
|
return fetchContent(path)()
|
|
}
|
|
}
|
|
|
|
func ExtractHints(content string) []string {
|
|
re := regexp.MustCompile(`(?s)\*\*(.*?)\*\*|__(.*?)__`)
|
|
matches := re.FindAllStringSubmatch(content, -1)
|
|
hints := make([]string, 0)
|
|
for _, match := range matches {
|
|
if len(match) > 1 && match[1] != "" {
|
|
hints = append(hints, match[1])
|
|
} else if len(match) > 2 && match[2] != "" {
|
|
hints = append(hints, match[2])
|
|
}
|
|
}
|
|
return hints
|
|
}
|
|
|
|
func (m NoteView) renderNote() string {
|
|
// TODO: this I need to refactor, too many branches
|
|
if m.Path == "" {
|
|
return "" // no note is loaded, don't need to bother with anything
|
|
}
|
|
var titleText, contentText string
|
|
if len(m.sections) > 0 {
|
|
titleText = m.sections[m.currentSectionIndex].Title
|
|
contentText = m.sections[m.currentSectionIndex].Content
|
|
} else {
|
|
contentText = m.rawContent
|
|
}
|
|
|
|
title, err1 := m.mdRenderer.Render(titleText)
|
|
if err1 != nil {
|
|
title = titleText + "\n\n"
|
|
}
|
|
|
|
var body string
|
|
var err2 error
|
|
|
|
switch m.viewState {
|
|
case StateContent:
|
|
body, err2 = m.mdRenderer.Render(contentText)
|
|
case StateHints:
|
|
currentHints := []string{}
|
|
if m.currentSectionIndex < len(m.sections) {
|
|
currentHints = m.sections[m.currentSectionIndex].Hints
|
|
}
|
|
|
|
if len(currentHints) == 0 {
|
|
body, err2 = m.mdRenderer.Render("\nNo hints available.")
|
|
} else {
|
|
// Format hints as a list
|
|
hintsList := ""
|
|
for _, h := range currentHints {
|
|
hintsList += "- " + h + "\n"
|
|
}
|
|
body, err2 = m.mdRenderer.Render(hintsList)
|
|
}
|
|
case StateTitleOnly:
|
|
body = "" // TOOD: there was a message here but I removed it, maybe remove enum entry as well
|
|
// or improve this part ui
|
|
}
|
|
|
|
if err2 != nil {
|
|
return title + "\nError rendering content."
|
|
}
|
|
|
|
// HACK:
|
|
// bubbletea does not allow me to configure newlines on lists all that well (afaiu)
|
|
// so so I've removes all block level spacing and added a \n IFF the start is not a list
|
|
isListStart := m.viewState == StateHints && len(m.sections[m.currentSectionIndex].Hints) > 0 || m.viewState == StateContent && (strings.HasPrefix(contentText, "-") || strings.HasPrefix(contentText, "*"))
|
|
if !isListStart {
|
|
title = title + "\n"
|
|
}
|
|
|
|
return title + body
|
|
}
|