Commit Diff


commit - 89383efba9faff5db72cfb37636b84952dfd140a
commit + d24927d6aacc10158c3a1ce30082852c5e5ea884
blob - a6ae17a21324e8f47e4ad8d80f08d677040d1933
blob + c46eda434470879de60b7789166c99ffa28a6da0
--- internal/code/code.go
+++ internal/code/code.go
@@ -12,6 +12,11 @@ import (
 	"time"
 )
 
+// RunMarker on a slide, on its own line, makes slides execute that
+// slide's code blocks automatically when the slide is shown and hide the
+// source, displaying only the output.
+const RunMarker = "<!-- run -->"
+
 // Block represents a code block.
 type Block struct {
 	Code     string
blob - /dev/null
blob + a87cd0a641371410eaf3a0943fd84fbbb9010952 (mode 644)
--- /dev/null
+++ internal/code/warm.go
@@ -0,0 +1,32 @@
+package code
+
+import (
+	"os/exec"
+	"strings"
+)
+
+// Warm primes slow interpreters used by auto-run (<!-- run -->) slides so
+// the first slide that executes doesn't stall on a cold start. Currently
+// only Julia benefits (its first `using` of a plotting package can take
+// tens of seconds to precompile); the warm-up is a single throwaway
+// process kicked off in the background. Best effort: failures are ignored.
+func Warm(slides []string) {
+	for _, s := range slides {
+		if !strings.Contains(s, RunMarker) {
+			continue
+		}
+		blocks, err := Parse(s)
+		if err != nil {
+			continue
+		}
+		for _, b := range blocks {
+			if b.Language == Julia {
+				go func() {
+					_ = exec.Command("julia", "--startup-file=no", "-e",
+						"try; using UnicodePlots; catch; end").Run()
+				}()
+				return
+			}
+		}
+	}
+}
blob - /dev/null
blob + a899888b4ca3011a15de3e3df971623389a345f4 (mode 644)
--- /dev/null
+++ internal/model/autorun_test.go
@@ -0,0 +1,23 @@
+package model
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestCodeFenceRe(t *testing.T) {
+	in := "title\n\nintro line\n\n```julia\nusing UnicodePlots\nprintln(1)\n```\n\ntail\n"
+	got := codeFenceRe.ReplaceAllString(in, "")
+	if strings.Contains(got, "using UnicodePlots") || strings.Contains(got, "```") {
+		t.Fatalf("fence not stripped: %q", got)
+	}
+	if !strings.Contains(got, "intro line") || !strings.Contains(got, "tail") {
+		t.Fatalf("prose lost: %q", got)
+	}
+
+	// tilde fences and a missing language tag both count
+	in2 := "~~~\ncode here\n~~~\n"
+	if codeFenceRe.ReplaceAllString(in2, "X") != "X" {
+		t.Errorf("tilde/no-lang fence not matched: %q", codeFenceRe.ReplaceAllString(in2, "X"))
+	}
+}
blob - a809a9a71d0d14c3a9b74133377f9bfe93159819
blob + 956d443a630071fec657342a78321b9a1072d1db
--- internal/model/model.go
+++ internal/model/model.go
@@ -8,6 +8,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"regexp"
 	"strconv"
 	"strings"
 	"sync"
@@ -85,6 +86,10 @@ type Model struct {
 	// execImages are the image files (plots, etc.) that execution left
 	// behind, in display order.
 	execImages []string
+	// execCache holds an auto-run (<!-- run -->) slide's last execution
+	// result, keyed by page, so revisiting the slide restores its output
+	// instead of re-running.
+	execCache map[int]codeExecutedMsg
 }
 
 // nextKeys/prevKeys are the single-keystroke navigation keys (as opposed to
@@ -394,6 +399,45 @@ func centerLines(s string, width int) string {
 	return strings.Join(lines, "\n")
 }
 
+// codeFenceRe matches a whole fenced code block (``` or ~~~), language
+// tag optional. Used to hide the source on an auto-run slide.
+var codeFenceRe = regexp.MustCompile("(?s)(```|~~~)[a-zA-Z0-9]*\n.*?\n(```|~~~)[ \t]*\n?")
+
+// autoRun handles a slide that carries code.RunMarker (<!-- run -->): its
+// code blocks execute automatically on arrival and only their output is
+// shown. It restores a cached result for an already-visited slide, or
+// returns a command to execute the blocks for a fresh one (nil if the
+// slide has no marker or no parseable block).
+func (m *Model) autoRun() tea.Cmd {
+	if m.Page < 0 || m.Page >= len(m.Slides) {
+		return nil
+	}
+	slide := m.Slides[m.Page]
+	if !strings.Contains(slide, code.RunMarker) {
+		return nil
+	}
+	if cached, ok := m.execCache[m.Page]; ok {
+		m.executing = false
+		m.VirtualText = cached.text
+		m.execImages = cached.images
+		return nil
+	}
+	blocks, err := code.Parse(slide)
+	if err != nil {
+		return nil
+	}
+	maxCols, maxRows := m.mediaSize()
+	for i := range blocks {
+		if blocks[i].Language == code.Julia {
+			blocks[i].Env = juliaEnv(maxCols, maxRows)
+		}
+	}
+	m.VirtualText = ""
+	m.execImages = nil
+	m.executing = true
+	return executeCodeCmd(m.Page, blocks)
+}
+
 // resolveThemeRef makes a relative `theme:` path in the frontmatter
 // resolve against the presentation file's directory (as bibliography and
 // images already do), not the process's working directory. Builtin
@@ -471,6 +515,14 @@ func (m *Model) Load() error {
 	// warmer.
 	go latex.Warm(append([]string(nil), m.Slides...), m.darkBG)
 
+	// Prime the interpreter for any auto-run (<!-- run -->) slide so the
+	// first one to execute doesn't stall on a cold start.
+	go code.Warm(append([]string(nil), m.Slides...))
+
+	if m.execCache == nil {
+		m.execCache = make(map[int]codeExecutedMsg)
+	}
+
 	return nil
 }
 
@@ -557,6 +609,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 			}
 			return m, nil
 		case "ctrl+c", "q":
+			for _, r := range m.execCache {
+				for _, d := range r.dirs {
+					os.RemoveAll(d)
+				}
+			}
 			return m, tea.Quit
 		default:
 			oldBuffer := m.buffer
@@ -590,11 +647,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 			}
 
 			m.refreshMediaBudget()
+			autoCmd := m.autoRun()
 			if !m.graphicsOK {
-				return m, nil
+				return m, autoCmd
 			}
 			maxCols, maxRows := m.mediaSize()
-			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], maxCols, maxRows, m.darkBG)
+			imgCmd := loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], maxCols, maxRows, m.darkBG)
+			if autoCmd != nil {
+				return m, tea.Batch(imgCmd, autoCmd)
+			}
+			return m, imgCmd
 		}
 
 	case uv.KittyGraphicsEvent:
@@ -623,6 +685,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 		m.VirtualText = msg.text
 		m.execDirs = msg.dirs
 		m.execImages = msg.images
+		if strings.Contains(m.Slides[msg.page], code.RunMarker) {
+			if m.execCache == nil {
+				m.execCache = make(map[int]codeExecutedMsg)
+			}
+			m.execCache[msg.page] = msg
+		}
 		if !m.graphicsOK || len(msg.images) == 0 {
 			return m, nil
 		}
@@ -669,6 +737,15 @@ func (m Model) renderSlide(md string, includeVirtualTe
 		slide = strings.ReplaceAll(slide, centerMarker, "")
 	}
 
+	// An auto-run slide (<!-- run -->) hides its source: strip the marker
+	// and every fenced code block, leaving only prose. The output is
+	// appended below via includeVirtualText.
+	if strings.Contains(slide, code.RunMarker) {
+		slide = strings.ReplaceAll(slide, code.RunMarker+"\n", "")
+		slide = strings.ReplaceAll(slide, code.RunMarker, "")
+		slide = codeFenceRe.ReplaceAllString(slide, "")
+	}
+
 	maxCols, maxRows := m.mediaSize()
 	spin := m.spinner.View()