Commit Diff


commit - 76524214d5cfa412ee9ae29698f2114a5d57f742
commit + 939e2b9e87d44d10145e5e533d2efe8fe2b5bbb7
blob - /dev/null
blob + 3671cbdd35692f60d6c716bc3607ca387f494b47 (mode 644)
--- /dev/null
+++ examples/reveal.md
@@ -0,0 +1,36 @@
+---
+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
@@ -8,6 +8,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
@@ -21,12 +22,14 @@ import (
 	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"
 )
@@ -62,8 +65,25 @@ type Model struct {
 	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
@@ -319,13 +339,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 		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
 			}
@@ -363,13 +406,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 	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()
@@ -391,7 +437,9 @@ func (m Model) View() tea.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)
 	}
@@ -410,7 +458,32 @@ func (m Model) View() tea.View {
 		}
 		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
@@ -0,0 +1,22 @@
+// 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
@@ -0,0 +1,48 @@
+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)
+			}
+		})
+	}
+}