Commit Diff


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(
-		"<file>", f.Name(),
+		"<file>", sourcePath,
 		// <name>: file name without extension and without path
-		"<name>", filepath.Base(strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))),
-		"<path>", filepath.Dir(f.Name()),
+		"<name>", strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath)),
+		"<path>", 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", "<file>"}},
+		// --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", "<file>"}},
 	},
 	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
 }