commit 939e2b9e87d44d10145e5e533d2efe8fe2b5bbb7 from: ale date: Sat Aug 1 03:21:34 2026 UTC Add progressive reveal within a slide via 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: + +``` + +``` + +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 + +- Second point, appears on the next keypress + +- 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: + +$$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 () that stays invisible in any other +// markdown renderer. +package reveal + +import "regexp" + +var pauseRe = regexp.MustCompile(`(?m)^[ \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\n- one\n\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 \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) + } + }) + } +}