commit dd0408215b51197334ac5262c29d457559f41a92 from: ale date: Sat Aug 1 04:35:18 2026 UTC Run code execution async and show plots as real images Ctrl+E now runs off the render path (a tea.Cmd), so a slow first-time compile (Julia's Plots/Makie precompilation can take minutes) no longer freezes the presentation -- the existing spinner shows while it runs. Any image file a code block's execution leaves behind (savefig, save, ...) is now picked up from its own temp working directory and shown below the text output, reusing the image package's Kitty pipeline. For Julia specifically: --color=yes so UnicodePlots' ANSI output survives being captured (Julia disables color when stdout isn't a TTY), COLUMNS/ LINES sized to the slide so it has a real canvas instead of a fixed 80x24 fallback, and GKSwstype=100 so GR.jl/Plots.jl's GR backend never tries to open a window it can't display. julia-bin added to the dev shell for local verification. commit - 939e2b9e87d44d10145e5e533d2efe8fe2b5bbb7 commit + dd0408215b51197334ac5262c29d457559f41a92 blob - /dev/null blob + a5aeff82ee80eb01d2b52f5fa9d8eb2728087122 (mode 644) --- /dev/null +++ examples/julia.md @@ -0,0 +1,49 @@ +--- +author: Gopher +paging: Slide %d / %d +--- + +# Julia plots + +Press `ctrl+e` on a Julia code block to run it. Text output (like +UnicodePlots) shows up right on the slide; if the code saves an image file +(`savefig`/`save`), it's shown below the output as a real image. + +First run of each library can take a while (Julia precompiles it) -- +that's expected, not a hang. + +--- + +### UnicodePlots.jl + +Draws straight to stdout, so it needs no image at all. + +```julia +using UnicodePlots +println(lineplot(1:100, x -> sin(x / 5), title="sin(x / 5)")) +``` + +--- + +### Plots.jl + GR + +`GKSwstype` is set automatically so this never tries to open a window. + +```julia +using Plots +gr() +plot(1:100, x -> cos(x / 5), title="cos(x / 5)", legend=false) +savefig("plot.png") +``` + +--- + +### CairoMakie + +```julia +using CairoMakie +fig = Figure() +ax = Axis(fig[1, 1], title = "x²") +lines!(ax, 1:100, x -> x^2) +save("fig.png", fig) +``` blob - 0ff54e3ad8f5d9c80d1fdcae55f94313ef2dffa6 blob + 8cb206edd98d8dc80a02d00fd9828abb9378233e --- flake.nix +++ flake.nix @@ -27,6 +27,9 @@ ghostscript imagemagick + # ejecución de código en las slides: Ctrl+E + julia-bin + # comodidad git ]; blob - 9e0182a06a7cae144a1ffb2923d298980d1a110a blob + a6ae17a21324e8f47e4ad8d80f08d677040d1933 --- internal/code/code.go +++ internal/code/code.go @@ -2,10 +2,12 @@ package code import ( "errors" + "fmt" "os" "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" ) @@ -14,6 +16,11 @@ import ( type Block struct { Code string Language string + // Env holds extra environment variables to set for this block's + // execution, on top of the process's own environment. Used to tune + // language-specific runtime behavior (e.g. terminal width for + // UnicodePlots.jl) without changing Execute's signature. + Env map[string]string } // Result represents the output for an executed code block. @@ -21,6 +28,12 @@ type Result struct { Out string ExitCode int ExecutionTime time.Duration + // WorkDir is the temporary directory the code ran in. The caller owns + // its cleanup (os.RemoveAll) once Result is no longer needed. + WorkDir string + // Images holds the absolute paths of any image files (plots, etc.) + // the code left behind in WorkDir, in name order. + Images []string } // ?: means non-capture group @@ -65,7 +78,22 @@ const ( ExitCodeInternalError = -1 ) -// Execute takes a code.Block and returns the output of the executed code +// imageExtensions are the file extensions Execute looks for, in WorkDir, +// once a code block finishes running -- anything a plotting library might +// have saved there. SVG is deliberately excluded: Go's image package can't +// decode it without a new dependency. +var imageExtensions = map[string]bool{ + ".png": true, + ".jpg": true, + ".jpeg": true, + ".gif": true, +} + +// Execute takes a code.Block and returns the output of the executed code. +// It runs the code in its own temporary directory (Result.WorkDir), so a +// relative path the code saves a file to (e.g. a plot) lands somewhere +// predictable; the caller is responsible for removing that directory once +// done with the result. func Execute(code Block) Result { // Check supported language language, ok := Languages[code.Language] @@ -76,20 +104,17 @@ func Execute(code Block) Result { } } - // Write the code block to a temporary file - f, err := os.CreateTemp(os.TempDir(), "slides-*."+Languages[code.Language].Extension) + workDir, err := os.MkdirTemp(os.TempDir(), "slides-*") if err != nil { return Result{ - Out: "Error: could not create file", + Out: "Error: could not create directory", ExitCode: ExitCodeInternalError, } } - defer f.Close() - defer os.Remove(f.Name()) - - _, err = f.WriteString(code.Code) - if err != nil { + sourcePath := filepath.Join(workDir, "code."+language.Extension) + if err := os.WriteFile(sourcePath, []byte(code.Code), 0o600); err != nil { + os.RemoveAll(workDir) return Result{ Out: "Error: could not write to file", ExitCode: ExitCodeInternalError, @@ -103,12 +128,20 @@ func Execute(code Block) Result { // replacer for commands repl := strings.NewReplacer( - "", f.Name(), + "", sourcePath, // : file name without extension and without path - "", filepath.Base(strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))), - "", filepath.Dir(f.Name()), + "", strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath)), + "", workDir, ) + var env []string + if len(code.Env) > 0 { + env = os.Environ() + for k, v := range code.Env { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + } + // For accuracy of program execution speed, we can't put anything after // recording the start time or before recording the end time. start := time.Now() @@ -121,6 +154,10 @@ func Execute(code Block) Result { } // execute and write output cmd := exec.Command(command[0], command[1:]...) + cmd.Dir = workDir + if env != nil { + cmd.Env = env + } out, err := cmd.Output() if err != nil { output.Write([]byte(err.Error())) @@ -144,5 +181,38 @@ func Execute(code Block) Result { Out: output.String(), ExitCode: exitCode, ExecutionTime: end.Sub(start), + WorkDir: workDir, + Images: findImages(workDir, sourcePath), } } + +// findImages lists the image files a code block's execution left behind in +// dir, sorted by name, skipping the source file itself. +func findImages(dir, sourcePath string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + var names []string + for _, e := range entries { + if e.IsDir() { + continue + } + path := filepath.Join(dir, e.Name()) + if path == sourcePath { + continue + } + if !imageExtensions[strings.ToLower(filepath.Ext(e.Name()))] { + continue + } + names = append(names, e.Name()) + } + sort.Strings(names) + + images := make([]string, len(names)) + for i, name := range names { + images[i] = filepath.Join(dir, name) + } + return images +} blob - eb4691810fac38d427d50859de79259ddebbfaaa blob + 282bb3efc4d364a2691d8464750c51b888674580 --- internal/code/execute_test.go +++ internal/code/execute_test.go @@ -1,6 +1,8 @@ package code_test import ( + "os" + "path/filepath" "testing" "github.com/maaslalani/slides/internal/code" @@ -73,3 +75,44 @@ func main() { } } } + +func TestExecuteCapturesImages(t *testing.T) { + r := code.Execute(code.Block{ + Code: `touch plot.png b.txt z.jpg`, + Language: "bash", + }) + defer os.RemoveAll(r.WorkDir) + + if r.ExitCode != 0 { + t.Fatalf("unexpected exit code %d, out=%q", r.ExitCode, r.Out) + } + if r.WorkDir == "" { + t.Fatal("expected a non-empty WorkDir") + } + + want := []string{ + filepath.Join(r.WorkDir, "plot.png"), + filepath.Join(r.WorkDir, "z.jpg"), + } + if len(r.Images) != len(want) { + t.Fatalf("got %d images, want %d: %v", len(r.Images), len(want), r.Images) + } + for i := range want { + if r.Images[i] != want[i] { + t.Errorf("image %d: got %s, want %s", i, r.Images[i], want[i]) + } + } +} + +func TestExecuteEnv(t *testing.T) { + r := code.Execute(code.Block{ + Code: `echo "$FOO"`, + Language: "bash", + Env: map[string]string{"FOO": "bar"}, + }) + defer os.RemoveAll(r.WorkDir) + + if r.Out != "bar\n" { + t.Fatalf("got %q, want %q", r.Out, "bar\n") + } +} blob - 6f7fdb6bfa8b34c669bd3c257ce7e4c43bc5a901 blob + 1f5d1f5819f31b37bd057cccb32e656d13556e5c --- internal/code/languages.go +++ internal/code/languages.go @@ -100,7 +100,10 @@ var Languages = map[string]Language{ }, Julia: { Extension: "jl", - Commands: cmds{{"julia", ""}}, + // --color=yes: Julia turns ANSI color off by default when stdout + // isn't a TTY (always true here, since output is captured), which + // would otherwise flatten UnicodePlots.jl output to plain text. + Commands: cmds{{"julia", "--color=yes", ""}}, }, Cpp: { Extension: "cpp", blob - 7f505567cf54150d321c3de354850408031306e9 blob + ee3e273280944c37713f8610fb9e271bda7f3b23 --- internal/image/image.go +++ internal/image/image.go @@ -43,7 +43,12 @@ const sentinelClose = "" var sentinelRe = regexp.MustCompile(sentinelOpen + `(\d+)` + sentinelClose) -func sentinel(index int) string { +// Sentinel returns the marker line Preprocess/Expand use, in the Private +// Use Area, to stand in for an image at position index -- exported so +// callers that build their own placements outside of Preprocess (e.g. +// images produced by executing a code block) can inject the same markers +// and still resolve them through Expand. +func Sentinel(index int) string { return fmt.Sprintf("%s%d%s", sentinelOpen, index, sentinelClose) } @@ -67,7 +72,7 @@ func Preprocess(markdown, loading string, lookup func( markdown = strings.Replace(markdown, ref.Raw, fallback, 1) continue } - replacement := sentinel(len(placements)) + replacement := Sentinel(len(placements)) if ref.Caption != "" { replacement += "\n\n*" + ref.Caption + "*" } blob - 6d64f7e130f563af0aaf31b0128f0123895ea091 blob + 06971dd83e430fd638a39992dd0d7f24c60c8c31 --- internal/model/model.go +++ internal/model/model.go @@ -68,6 +68,15 @@ type Model struct { // step is the current slide's reveal progress, an index into // reveal.Steps(current slide). step int + + // executing is true while a ctrl+e code execution is in flight. + executing bool + // execDirs are the temporary directories the current slide's last + // ctrl+e execution ran in, owned by Model until the page changes. + execDirs []string + // execImages are the image files (plots, etc.) that execution left + // behind, in display order. + execImages []string } // nextKeys/prevKeys are the single-keystroke navigation keys (as opposed to @@ -163,6 +172,49 @@ func (m *Model) mediaSize() (maxCols, maxRows int) { return contentWidth, maxRows } +// execMediaSize is the column/row budget for a code execution's plot: half +// of mediaSize's row budget, so the code's stdout still has room next to +// the image it produced. +func (m *Model) execMediaSize() (maxCols, maxRows int) { + maxCols, maxRows = m.mediaSize() + maxRows /= 2 + if maxRows < 2 { + maxRows = 2 + } + return maxCols, maxRows +} + +// loadPathSeq loads path into images if it isn't cached yet, returning its +// Kitty transmit sequence. ok is false both on failure and when the entry +// was already cached (nothing new to send). +func loadPathSeq(images *image.Cache, path string, maxCols, maxRows int) (seq string, ok bool) { + _, seq, err := images.Load(path, maxCols, maxRows) + if err != nil || seq == "" { + return "", false + } + return seq, true +} + +// loadPathsCmd decodes and transmits any of paths not already cached. It +// does file I/O, so it must only run inside a tea.Cmd. +func loadPathsCmd(images *image.Cache, paths []string, maxCols, maxRows int) tea.Cmd { + if images == nil || len(paths) == 0 { + return nil + } + return func() tea.Msg { + var seqs []string + for _, path := range paths { + if seq, ok := loadPathSeq(images, path, maxCols, maxRows); ok { + seqs = append(seqs, seq) + } + } + if len(seqs) == 0 { + return nil + } + return imagesLoadedMsg{seqs: seqs} + } +} + // loadImagesCmd decodes and encodes any not-yet-cached images or display // math referenced by slide, off the render path. It does file I/O (and, // for display math, shells out to tectonic/gs), so it must only run @@ -177,29 +229,18 @@ func loadImagesCmd(images *image.Cache, baseDir, slide var seqs []string for _, ref := range refs { path := filepath.Join(baseDir, ref.Src) - if _, ok := images.Get(path, maxCols, maxRows); ok { - continue + if seq, ok := loadPathSeq(images, path, maxCols, maxRows); ok { + seqs = append(seqs, seq) } - _, seq, err := images.Load(path, maxCols, maxRows) - if err != nil || seq == "" { - continue - } - seqs = append(seqs, seq) } for _, b := range display { - cachePath := latex.CachePath(b.Source, darkBG) - if _, ok := images.Get(cachePath, maxCols, maxRows); ok { - continue - } renderedPath, err := latex.Render(b.Source, darkBG) if err != nil { continue } - _, seq, err := images.Load(renderedPath, maxCols, maxRows) - if err != nil || seq == "" { - continue + if seq, ok := loadPathSeq(images, renderedPath, maxCols, maxRows); ok { + seqs = append(seqs, seq) } - seqs = append(seqs, seq) } if len(seqs) == 0 { return nil @@ -208,6 +249,49 @@ func loadImagesCmd(images *image.Cache, baseDir, slide } } +// codeExecutedMsg carries the result of an async ctrl+e code execution. +// page pins it to the slide it was launched from, so a slide change while +// execution is still in flight doesn't apply a stale result. +type codeExecutedMsg struct { + page int + text string + dirs []string + images []string +} + +// executeCodeCmd runs blocks (potentially slow: Julia's first run compiles +// its dependencies) off the render path, in its own goroutine managed by +// bubbletea. +func executeCodeCmd(page int, blocks []code.Block) tea.Cmd { + return func() tea.Msg { + var outs []string + var dirs []string + var images []string + for _, block := range blocks { + res := code.Execute(block) + outs = append(outs, res.Out) + if res.WorkDir != "" { + dirs = append(dirs, res.WorkDir) + } + images = append(images, res.Images...) + } + return codeExecutedMsg{page: page, text: strings.Join(outs, "\n"), dirs: dirs, images: images} + } +} + +// juliaEnv tunes Julia's runtime for headless, non-interactive execution: +// GKSwstype=100 keeps GR.jl/Plots.jl's GR backend from trying to open a +// window it can't (it would otherwise hang waiting for a display), and +// COLUMNS/LINES give UnicodePlots.jl a real canvas size to draw into -- +// without a TTY, Julia's own displaysize() falls back to a fixed 80x24. +func juliaEnv(maxCols, maxRows int) map[string]string { + return map[string]string{ + "GKSwstype": "100", + "COLUMNS": strconv.Itoa(maxCols), + "LINES": strconv.Itoa(maxRows), + } +} + func fileWatchCmd() tea.Cmd { return tea.Every(time.Second, func(t time.Time) tea.Msg { return fileWatchMsg{} @@ -314,19 +398,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd // Go to next occurrence m.Search.Execute(&m) case "ctrl+e": - // Run code blocks + // Run code blocks. Execution happens off the render path + // (codeExecutedMsg/executeCodeCmd below): some languages + // (Julia, first run) take long enough to compile that running + // them inline here would freeze the whole presentation. blocks, err := code.Parse(m.Slides[m.Page]) if err != nil { // We couldn't parse the code block on the screen m.VirtualText = "\n" + err.Error() return m, nil } - var outs []string - for _, block := range blocks { - res := code.Execute(block) - outs = append(outs, res.Out) + maxCols, maxRows := m.mediaSize() + for i := range blocks { + if blocks[i].Language == code.Julia { + blocks[i].Env = juliaEnv(maxCols, maxRows) + } } - m.VirtualText = strings.Join(outs, "\n") + for _, d := range m.execDirs { + os.RemoveAll(d) + } + m.execDirs = nil + m.execImages = nil + m.VirtualText = "" + m.executing = true + return m, executeCodeCmd(m.Page, blocks) case "y": blocks, err := code.Parse(m.Slides[m.Page]) if err != nil { @@ -387,6 +482,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd case imagesLoadedMsg: return m, tea.Raw(strings.Join(msg.seqs, "")) + case codeExecutedMsg: + if msg.page != m.Page { + // The user navigated away while this execution was still + // running; its output belongs to a slide that's no longer + // current, so just clean up instead of displaying it. + for _, d := range msg.dirs { + os.RemoveAll(d) + } + return m, nil + } + m.executing = false + m.VirtualText = msg.text + m.execDirs = msg.dirs + m.execImages = msg.images + if !m.graphicsOK || len(msg.images) == 0 { + return m, nil + } + maxCols, maxRows := m.execMediaSize() + return m, loadPathsCmd(m.images, msg.images, maxCols, maxRows) + case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) @@ -439,6 +554,21 @@ func (m Model) renderSlide(md string, includeVirtualTe slide = strings.ReplaceAll(slide, "\t", tabSpaces) if includeVirtualText { slide += m.VirtualText + if m.executing { + slide += "\n" + spin + " ejecutando…" + } + if m.images != nil && len(m.execImages) > 0 { + execCols, execRows := m.execMediaSize() + for _, path := range m.execImages { + if entry, ok := m.images.Get(path, execCols, execRows); ok { + idx := len(placements) + slide += "\n" + image.Sentinel(idx) + placements = append(placements, image.Placement{Entry: entry}) + } else { + slide += "\n" + spin + " generando gráfico…" + } + } + } } if err != nil { slide = fmt.Sprintf("Error: Could not render markdown! (%v)", err) @@ -577,6 +707,12 @@ func (m *Model) SetPage(page int) { return } + for _, d := range m.execDirs { + os.RemoveAll(d) + } + m.execDirs = nil + m.execImages = nil + m.executing = false m.VirtualText = "" m.Page = page }