commit d24927d6aacc10158c3a1ce30082852c5e5ea884 from: ale date: Sun Aug 30 01:08:09 2026 UTC Add slide directive: auto-run code, hide source A slide carrying '' executes its code blocks automatically when it is shown and displays only their output -- the source is stripped before rendering. Built for terminal plots (UnicodePlots.jl): navigating to the slide draws the graph with no keystroke. - autoRun() fires executeCodeCmd on slide entry (batched with the image load), or restores a per-page cached result on revisit so the slide doesn't re-run and flicker. - code.Warm() kicks off a throwaway 'using UnicodePlots' at load so the first auto-run slide doesn't stall on Julia's precompile. - cached execution temp dirs are cleaned on quit. 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 = "" + // 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 () 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 () 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 (): 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 () 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 () 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()