commit - 89383efba9faff5db72cfb37636b84952dfd140a
commit + d24927d6aacc10158c3a1ce30082852c5e5ea884
blob - a6ae17a21324e8f47e4ad8d80f08d677040d1933
blob + c46eda434470879de60b7789166c99ffa28a6da0
--- internal/code/code.go
+++ internal/code/code.go
"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
+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
+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
"io"
"os"
"path/filepath"
+ "regexp"
"strconv"
"strings"
"sync"
// 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
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
// 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
}
}
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
}
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:
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
}
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()