commit - 939e2b9e87d44d10145e5e533d2efe8fe2b5bbb7
commit + dd0408215b51197334ac5262c29d457559f41a92
blob - /dev/null
blob + a5aeff82ee80eb01d2b52f5fa9d8eb2728087122 (mode 644)
--- /dev/null
+++ examples/julia.md
+---
+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
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
import (
"errors"
+ "fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
+ "sort"
"strings"
"time"
)
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.
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
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]
}
}
- // 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,
// 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()
}
// 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()))
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
package code_test
import (
+ "os"
+ "path/filepath"
"testing"
"github.com/maaslalani/slides/internal/code"
}
}
}
+
+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
},
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
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)
}
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
// 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
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
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
}
}
+// 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{}
// 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 {
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)
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)
return
}
+ for _, d := range m.execDirs {
+ os.RemoveAll(d)
+ }
+ m.execDirs = nil
+ m.execImages = nil
+ m.executing = false
m.VirtualText = ""
m.Page = page
}