commit - 76524214d5cfa412ee9ae29698f2114a5d57f742
commit + 939e2b9e87d44d10145e5e533d2efe8fe2b5bbb7
blob - /dev/null
blob + 3671cbdd35692f60d6c716bc3607ca387f494b47 (mode 644)
--- /dev/null
+++ examples/reveal.md
+---
+author: Gopher
+paging: Slide %d / %d
+---
+
+# Progressive reveal
+
+Split a slide into steps with an HTML comment on its own line:
+
+```
+<!-- pause -->
+```
+
+Press the same keys you'd use to advance/go back — space, `j`, `k`, arrows —
+to reveal or retract one step at a time. Jumping (`3j`, `5G`, `gg`) always
+skips straight to the target slide, fully revealed.
+
+---
+
+# One point at a time
+
+- First point, always visible
+<!-- pause -->
+- Second point, appears on the next keypress
+<!-- pause -->
+- Third point, appears on the keypress after that
+
+---
+
+# Works with anything on the slide
+
+An equation can wait for its own step:
+
+Rearranging Newton's second law:
+<!-- pause -->
+$$F = ma \implies a = \frac{F}{m}$$
blob - ce607787710a9f3f38f76d0b18321a4ac18023d4
blob + 6d64f7e130f563af0aaf31b0128f0123895ea091
--- internal/model/model.go
+++ internal/model/model.go
"io"
"os"
"path/filepath"
+ "strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
uv "github.com/charmbracelet/ultraviolet"
+ "charm.land/lipgloss/v2"
"github.com/charmbracelet/glamour"
"github.com/maaslalani/slides/internal/bib"
"github.com/maaslalani/slides/internal/code"
"github.com/maaslalani/slides/internal/image"
"github.com/maaslalani/slides/internal/latex"
"github.com/maaslalani/slides/internal/meta"
+ "github.com/maaslalani/slides/internal/reveal"
"github.com/maaslalani/slides/styles"
"github.com/muesli/termenv"
)
graphicsOK bool
darkBG bool
spinner spinner.Model
+ // step is the current slide's reveal progress, an index into
+ // reveal.Steps(current slide).
+ step int
}
+// nextKeys/prevKeys are the single-keystroke navigation keys (as opposed to
+// a numeric buffer or the "g"/"G" jump keys) -- pressing one of these is
+// what advances/retracts a reveal step before it pages, mirroring
+// navigation.Navigate's own key sets.
+var (
+ nextKeys = map[string]bool{" ": true, "down": true, "j": true, "right": true, "l": true, "enter": true, "n": true, "pgdown": true}
+ prevKeys = map[string]bool{"up": true, "k": true, "left": true, "h": true, "p": true, "pgup": true, "N": true}
+)
+
+func bufferIsNumeric(buffer string) bool {
+ _, err := strconv.Atoi(buffer)
+ return err == nil
+}
+
type fileWatchMsg struct{}
// imagesLoadedMsg carries the Kitty transmit sequences for images that
case "ctrl+c", "q":
return m, tea.Quit
default:
+ oldBuffer := m.buffer
newState := navigation.Navigate(navigation.State{
Buffer: m.buffer,
Page: m.Page,
TotalSlides: len(m.Slides),
}, keyPress)
m.buffer = newState.Buffer
- m.SetPage(newState.Page)
+
+ lastStep := len(reveal.Steps(m.Slides[m.Page])) - 1
+ switch {
+ case nextKeys[keyPress] && !bufferIsNumeric(oldBuffer) && m.step < lastStep:
+ // Reveal the next step of the current slide instead of
+ // paging forward.
+ m.step++
+ case prevKeys[keyPress] && !bufferIsNumeric(oldBuffer) && m.step > 0:
+ // Retract the current slide's own reveal before leaving it.
+ m.step--
+ case newState.Page != m.Page:
+ m.SetPage(newState.Page)
+ if prevKeys[keyPress] && !bufferIsNumeric(oldBuffer) {
+ // Arriving on a previous slide via a single "previous"
+ // keystroke shows it fully revealed, matching how it
+ // looked when it was last on screen; any jump (3j, 5G,
+ // gg) always lands at step zero.
+ m.step = len(reveal.Steps(m.Slides[m.Page])) - 1
+ } else {
+ m.step = 0
+ }
+ }
+
if !m.graphicsOK {
return m, nil
}
return m, nil
}
-// View renders the current slide in the presentation and the status bar which
-// contains the author, date, and pagination information.
-func (m Model) View() tea.View {
+// renderSlide runs one slide's markdown (or reveal step) through the full
+// render pipeline: image/LaTeX preprocessing, glamour, expansion of the
+// resulting placeholders, and the left margin. includeVirtualText controls
+// whether code-execution output is appended -- View's full-height
+// measurement pass leaves it out, since it's ephemeral and not part of the
+// slide's own content.
+func (m Model) renderSlide(md string, includeVirtualText bool) string {
contentWidth, _ := m.contentSize()
r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(contentWidth))
- slide := m.Slides[m.Page]
- slide = code.HideComments(slide)
+ slide := code.HideComments(md)
maxCols, maxRows := m.mediaSize()
spin := m.spinner.View()
slide, err := r.Render(slide)
slide = strings.ReplaceAll(slide, "\t", tabSpaces)
- slide += m.VirtualText
+ if includeVirtualText {
+ slide += m.VirtualText
+ }
if err != nil {
slide = fmt.Sprintf("Error: Could not render markdown! (%v)", err)
}
}
slide = strings.Join(lines, "\n")
}
+ return slide
+}
+// View renders the current slide in the presentation and the status bar which
+// contains the author, date, and pagination information.
+func (m Model) View() tea.View {
+ steps := reveal.Steps(m.Slides[m.Page])
+ step := m.step
+ if step > len(steps)-1 {
+ step = len(steps) - 1
+ }
+ if step < 0 {
+ step = 0
+ }
+
+ slide := m.renderSlide(steps[step], true)
+ if len(steps) > 1 {
+ // Pad the current (possibly partial) step to the height of the
+ // slide's final, fully revealed step, so JoinVertical's centering
+ // doesn't shift already-visible lines as more steps are revealed.
+ full := m.renderSlide(steps[len(steps)-1], false)
+ if diff := lipgloss.Height(full) - lipgloss.Height(slide); diff > 0 {
+ slide += strings.Repeat("\n", diff)
+ }
+ }
+
var left string
if m.Search.Active {
// render search bar
blob - /dev/null
blob + f5b4489100beef4c8c9e5ab48c408731a95218cb (mode 644)
--- /dev/null
+++ internal/reveal/reveal.go
+// Package reveal splits a slide into progressively revealed steps, marked
+// by an HTML comment (<!-- pause -->) that stays invisible in any other
+// markdown renderer.
+package reveal
+
+import "regexp"
+
+var pauseRe = regexp.MustCompile(`(?m)^[ \t]*<!--[ \t]*pause[ \t]*-->[ \t]*$`)
+
+// Steps splits slide into cumulative steps: step i contains everything up
+// to and including the i-th marker. A slide with no marker always yields
+// exactly one step, equal to the whole slide.
+func Steps(slide string) []string {
+ parts := pauseRe.Split(slide, -1)
+ steps := make([]string, len(parts))
+ acc := ""
+ for i, part := range parts {
+ acc += part
+ steps[i] = acc
+ }
+ return steps
+}
blob - /dev/null
blob + 2f3396ab2fb62ec2218cbc4ff5fbc7f8a81e3d4e (mode 644)
--- /dev/null
+++ internal/reveal/reveal_test.go
+package reveal_test
+
+import (
+ "reflect"
+ "testing"
+
+ "github.com/maaslalani/slides/internal/reveal"
+)
+
+func TestSteps(t *testing.T) {
+ tt := []struct {
+ name string
+ slide string
+ expected []string
+ }{
+ {
+ name: "no marker",
+ slide: "# Title\n\nSome content",
+ expected: []string{"# Title\n\nSome content"},
+ },
+ {
+ name: "two markers",
+ slide: "# Title\n<!-- pause -->\n- one\n<!-- pause -->\n- two",
+ expected: []string{
+ "# Title\n",
+ "# Title\n\n- one\n",
+ "# Title\n\n- one\n\n- two",
+ },
+ },
+ {
+ name: "marker with surrounding spaces",
+ slide: "intro\n <!-- pause --> \nrest",
+ expected: []string{
+ "intro\n",
+ "intro\n\nrest",
+ },
+ },
+ }
+
+ for _, tc := range tt {
+ t.Run(tc.name, func(t *testing.T) {
+ got := reveal.Steps(tc.slide)
+ if !reflect.DeepEqual(got, tc.expected) {
+ t.Errorf("Steps(%q) = %#v, want %#v", tc.slide, got, tc.expected)
+ }
+ })
+ }
+}