Files
slides/internal/model/model.go
Daniel ac106d1060 Vim-like search functionality (#112)
* feat: basic command system

* feat: implemented forward search, backward search and `:<slide>`-command

* fix: golint

* fix: removed `fmt.Sprintf`

* fix: proper args splitting, added `goto` example

* refactor: less verbose output, removed `goto`-example

* fix: ignore empty commands

* refactor: remove `:`-commands, moved `search` to `navigation` package

* feat: implemented different search-types: header-search, full-text search (case sensitive, insensitive and regex)

* feat: go to next occurrence with `ctrl+n`

* fix: regex search

* docs: added search instructions

* refactor: removed search types, style changes

* docs: update README.md

* docs: update README.md

* refactor: `GetSlides()` -> `Pages()`, `Buffer` -> `Query`

* feat: use `tea.KeyRunes` to allow international characters (such as `ä` or `動`)

* test: added test for search

* test: added ignore-case flag test
2021-10-27 12:29:20 -04:00

286 lines
6.1 KiB
Go

package model
import (
"bufio"
"errors"
"fmt"
"github.com/maaslalani/slides/internal/file"
"github.com/maaslalani/slides/internal/navigation"
"github.com/maaslalani/slides/internal/process"
"io"
"io/ioutil"
"os"
"strings"
"time"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/glamour"
"github.com/maaslalani/slides/internal/code"
"github.com/maaslalani/slides/internal/meta"
"github.com/maaslalani/slides/styles"
)
const (
delimiter = "\n---\n"
)
type Model struct {
Slides []string
Page int
Author string
Date string
Theme glamour.TermRendererOption
Paging string
FileName string
viewport viewport.Model
buffer string
// VirtualText is used for additional information that is not part of the
// original slides, it will be displayed on a slide and reset on page change
VirtualText string
search navigation.Search
}
type fileWatchMsg struct{}
var fileInfo os.FileInfo
func (m Model) Init() tea.Cmd {
if m.FileName == "" {
return nil
}
fileInfo, _ = os.Stat(m.FileName)
return fileWatchCmd()
}
func fileWatchCmd() tea.Cmd {
return tea.Every(time.Second, func(t time.Time) tea.Msg {
return fileWatchMsg{}
})
}
func (m *Model) Load() error {
var content string
var err error
if m.FileName != "" {
content, err = readFile(m.FileName)
} else {
content, err = readStdin()
}
if err != nil {
return err
}
content = strings.TrimPrefix(content, strings.TrimPrefix(delimiter, "\n"))
slides := strings.Split(content, delimiter)
metaData, exists := meta.New().Parse(slides[0])
// If the user specifies a custom configuration options
// skip the first "slide" since this is all configuration
if exists && len(slides) > 1 {
slides = slides[1:]
}
m.Slides = slides
m.Author = metaData.Author
m.Date = time.Now().Format(metaData.Date)
m.Paging = metaData.Paging
if m.Theme == nil {
m.Theme = styles.SelectTheme(metaData.Theme)
}
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height
return m, nil
case tea.KeyMsg:
keyPress := msg.String()
// add key presses to action buffer
if m.search.Active {
switch msg.Type {
case tea.KeyRunes:
k := string(msg.Runes)
// rune key: append to buffer
m.search.Write(k)
case tea.KeyEnter:
// execute current buffer
if m.search.Query != "" {
m.search.Execute(&m)
} else {
m.search.Done()
}
case tea.KeyBackspace:
// delete last char from buffer
m.search.Delete()
case tea.KeyCtrlC, tea.KeyEscape:
// quit command mode
m.search.Query = ""
m.search.Done()
}
return m, nil
}
switch keyPress {
case "/":
// Begin search
m.search.Begin()
return m, nil
case "ctrl+n":
// Go to next occurrence
m.search.Execute(&m)
case "ctrl+e":
// Run code blocks
blocks, err := code.Parse(m.Slides[m.Page])
if err != nil {
// We couldn't parse the code block on the screen
m.VirtualText = "\n" + err.Error()
return m, nil
}
var outs []string
for _, block := range blocks {
res := code.Execute(block)
outs = append(outs, res.Out)
}
m.VirtualText = strings.Join(outs, "\n")
case "ctrl+c", "q":
return m, tea.Quit
default:
newState := navigation.Navigate(navigation.State{
Buffer: m.buffer,
Page: m.Page,
TotalSlides: len(m.Slides),
}, keyPress)
if newState.Page != m.Page {
m.VirtualText = ""
}
m.buffer, m.Page = newState.Buffer, newState.Page
}
case fileWatchMsg:
newFileInfo, err := os.Stat(m.FileName)
if err == nil && newFileInfo.ModTime() != fileInfo.ModTime() {
fileInfo = newFileInfo
_ = m.Load()
if m.Page >= len(m.Slides) {
m.Page = len(m.Slides) - 1
}
}
return m, fileWatchCmd()
}
return m, nil
}
func (m Model) View() string {
r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(m.viewport.Width))
slide := m.Slides[m.Page]
slide, err := r.Render(slide)
slide += m.VirtualText
if err != nil {
slide = fmt.Sprintf("Error: Could not render markdown! (%v)", err)
}
slide = styles.Slide.Render(slide)
var left string
if m.search.Active {
// render search bar
left = styles.ActionStatus.Render(fmt.Sprintf("/%s", m.search.Query))
} else {
// render author and date
left = styles.Author.Render(m.Author) + styles.Date.Render(m.Date)
}
right := styles.Page.Render(m.paging())
status := styles.Status.Render(styles.JoinHorizontal(left, right, m.viewport.Width))
return styles.JoinVertical(slide, status, m.viewport.Height)
}
func (m *Model) paging() string {
switch strings.Count(m.Paging, "%d") {
case 2:
return fmt.Sprintf(m.Paging, m.Page+1, len(m.Slides))
case 1:
return fmt.Sprintf(m.Paging, m.Page+1)
default:
return m.Paging
}
}
func readFile(path string) (string, error) {
s, err := os.Stat(path)
if err != nil {
return "", errors.New("could not read file")
}
if s.IsDir() {
return "", errors.New("can not read directory")
}
b, err := ioutil.ReadFile(path)
if err != nil {
return "", err
}
content := string(b)
// Pre-process slides if the file is executable to avoid
// unintentional code execution when presenting slides
if file.IsExecutable(s) {
// Remove shebang if file has one
if strings.HasPrefix(content, "#!") {
content = strings.Join(strings.SplitN(content, "\n", 2)[1:], "\n")
}
content = process.Pre(content)
}
return content, err
}
func readStdin() (string, error) {
stat, err := os.Stdin.Stat()
if err != nil {
return "", err
}
if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 {
return "", errors.New("no slides provided")
}
reader := bufio.NewReader(os.Stdin)
var b strings.Builder
for {
r, _, err := reader.ReadRune()
if err != nil && err == io.EOF {
break
}
_, err = b.WriteRune(r)
if err != nil {
return "", err
}
}
return b.String(), nil
}
func (m *Model) CurrentPage() int {
return m.Page
}
func (m *Model) SetPage(page int) {
m.Page = page
}
func (m *Model) Pages() []string {
return m.Slides
}