Commit Diff


commit - 372f60546819d91aaf4368cb7ea2b8a980156bfc
commit + 1686f28a01bc9570c6a1149302570e0f37f14171
blob - 5e48cfac7a6d71ae8cbc008d80be16e8c108740a
blob + b5fd7cedd7d4b9f52b460685e9fd9454a5844acf
--- internal/image/cache.go
+++ internal/image/cache.go
@@ -15,29 +15,23 @@ import (
 // round-trip to the terminal, so v1 uses this fixed assumption.
 const cellWidthOverHeight = 0.5
 
-// defaultMaxCols caps how wide a single image gets by default, so one
-// picture doesn't consume the whole slide width on a very wide terminal.
-const defaultMaxCols = 60
-
-// maxRows caps how tall a single image gets, so a narrow/tall image
-// doesn't consume the whole slide height.
-const maxRows = 20
-
 // Entry is a resolved, ready-to-place image.
 type Entry struct {
-	ID   int
-	Cols int
-	Rows int
+	ID      int
+	Cols    int
+	Rows    int
+	PadLeft int // spaces to prepend to each row, to center it within maxCols
 }
 
 type cacheKey struct {
 	path    string
 	maxCols int
+	maxRows int
 }
 
 // Cache decodes and transmits images over the Kitty Graphics Protocol,
-// keyed by resolved path and target width, and assigns each a stable ID
-// for the process's lifetime.
+// keyed by resolved path and target size budget, and assigns each a
+// stable ID for the process's lifetime.
 type Cache struct {
 	mu      sync.RWMutex
 	entries map[cacheKey]Entry
@@ -48,12 +42,12 @@ func NewCache() *Cache {
 	return &Cache{entries: make(map[cacheKey]Entry), nextID: 1}
 }
 
-// Get returns the entry for path/maxCols if it has already been loaded.
-// It does no I/O and is safe to call from View() on every frame.
-func (c *Cache) Get(path string, maxCols int) (Entry, bool) {
+// Get returns the entry for path/maxCols/maxRows if it has already been
+// loaded. It does no I/O and is safe to call from View() on every frame.
+func (c *Cache) Get(path string, maxCols, maxRows int) (Entry, bool) {
 	c.mu.RLock()
 	defer c.mu.RUnlock()
-	e, ok := c.entries[cacheKey{path, maxCols}]
+	e, ok := c.entries[cacheKey{path, maxCols, maxRows}]
 	return e, ok
 }
 
@@ -62,8 +56,8 @@ func (c *Cache) Get(path string, maxCols int) (Entry, 
 // does file I/O and image decoding, so callers must only invoke it from a
 // tea.Cmd, never from View(). If the entry is already cached, transmitSeq
 // is empty and there is nothing new to send.
-func (c *Cache) Load(path string, maxCols int) (entry Entry, transmitSeq string, err error) {
-	if e, ok := c.Get(path, maxCols); ok {
+func (c *Cache) Load(path string, maxCols, maxRows int) (entry Entry, transmitSeq string, err error) {
+	if e, ok := c.Get(path, maxCols, maxRows); ok {
 		return e, "", nil
 	}
 
@@ -78,13 +72,17 @@ func (c *Cache) Load(path string, maxCols int) (entry 
 		return Entry{}, "", err
 	}
 
-	cols, rows := fitSize(img.Bounds(), maxCols)
+	cols, rows := fitSize(img.Bounds(), maxCols, maxRows)
+	padLeft := (maxCols - cols) / 2
+	if padLeft < 0 {
+		padLeft = 0
+	}
 
 	c.mu.Lock()
 	id := c.nextID
 	c.nextID++
-	e := Entry{ID: id, Cols: cols, Rows: rows}
-	c.entries[cacheKey{path, maxCols}] = e
+	e := Entry{ID: id, Cols: cols, Rows: rows, PadLeft: padLeft}
+	c.entries[cacheKey{path, maxCols, maxRows}] = e
 	c.mu.Unlock()
 
 	seq, err := TransmitSeq(img, id, cols, rows)
@@ -95,21 +93,20 @@ func (c *Cache) Load(path string, maxCols int) (entry 
 }
 
 // fitSize picks a cols x rows grid that preserves the image's aspect
-// ratio within the column/row caps.
-func fitSize(bounds stdimage.Rectangle, maxCols int) (cols, rows int) {
+// ratio, filling as much of the maxCols x maxRows budget as it can.
+func fitSize(bounds stdimage.Rectangle, maxCols, maxRows int) (cols, rows int) {
 	w, h := bounds.Dx(), bounds.Dy()
 	if w <= 0 || h <= 0 {
 		return 1, 1
 	}
+	if maxCols < 1 {
+		maxCols = 1
+	}
+	if maxRows < 1 {
+		maxRows = 1
+	}
 
 	cols = maxCols
-	if cols > defaultMaxCols {
-		cols = defaultMaxCols
-	}
-	if cols < 1 {
-		cols = 1
-	}
-
 	rows = int(float64(cols) * cellWidthOverHeight * float64(h) / float64(w))
 	if rows > maxRows {
 		rows = maxRows
@@ -121,5 +118,8 @@ func fitSize(bounds stdimage.Rectangle, maxCols int) (
 	if cols < 1 {
 		cols = 1
 	}
+	if cols > maxCols {
+		cols = maxCols
+	}
 	return cols, rows
 }
blob - 7ad42469e8a9b3ce56171b30260b7cea266393bc
blob + 8dcd863b4cb8abd74a885a58cf2c7246a7ceecf3
--- internal/image/cache_test.go
+++ internal/image/cache_test.go
@@ -35,36 +35,39 @@ func TestCache_LoadAndGet(t *testing.T) {
 
 	c := image.NewCache()
 
-	_, ok := c.Get(path, 40)
+	_, ok := c.Get(path, 40, 20)
 	assert.False(t, ok, "not loaded yet")
 
-	entry, seq, err := c.Load(path, 40)
+	entry, seq, err := c.Load(path, 40, 20)
 	require.NoError(t, err)
 	assert.NotEmpty(t, seq)
 	assert.Equal(t, 1, entry.ID)
 	assert.Equal(t, 40, entry.Cols)
 
-	got, ok := c.Get(path, 40)
+	got, ok := c.Get(path, 40, 20)
 	assert.True(t, ok)
 	assert.Equal(t, entry, got)
 
-	entry2, seq2, err := c.Load(path, 40)
+	entry2, seq2, err := c.Load(path, 40, 20)
 	require.NoError(t, err)
 	assert.Equal(t, entry, entry2)
 	assert.Empty(t, seq2, "reloading a cached entry should not retransmit")
 }
 
-func TestCache_DifferentMaxColsGetsDifferentID(t *testing.T) {
+func TestCache_DifferentBudgetGetsDifferentID(t *testing.T) {
 	dir := t.TempDir()
 	path := writeTestPNG(t, dir, "img.png", 100, 100)
 
 	c := image.NewCache()
-	e1, _, err := c.Load(path, 40)
+	e1, _, err := c.Load(path, 40, 20)
 	require.NoError(t, err)
-	e2, _, err := c.Load(path, 20)
+	e2, _, err := c.Load(path, 20, 20)
 	require.NoError(t, err)
+	e3, _, err := c.Load(path, 40, 5)
+	require.NoError(t, err)
 
-	assert.NotEqual(t, e1.ID, e2.ID)
+	assert.NotEqual(t, e1.ID, e2.ID, "different maxCols should be a different entry")
+	assert.NotEqual(t, e1.ID, e3.ID, "different maxRows should be a different entry")
 }
 
 func TestCache_TallImageCapsRows(t *testing.T) {
@@ -72,7 +75,7 @@ func TestCache_TallImageCapsRows(t *testing.T) {
 	path := writeTestPNG(t, dir, "tall.png", 10, 1000)
 
 	c := image.NewCache()
-	entry, _, err := c.Load(path, 60)
+	entry, _, err := c.Load(path, 60, 20)
 	require.NoError(t, err)
 
 	assert.LessOrEqual(t, entry.Rows, 20)
@@ -81,6 +84,34 @@ func TestCache_TallImageCapsRows(t *testing.T) {
 
 func TestCache_MissingFile(t *testing.T) {
 	c := image.NewCache()
-	_, _, err := c.Load("/no/such/file.png", 40)
+	_, _, err := c.Load("/no/such/file.png", 40, 20)
 	assert.Error(t, err)
 }
+
+func TestCache_CentersNarrowerThanBudget(t *testing.T) {
+	dir := t.TempDir()
+	// A very tall, narrow image: the row budget forces cols well under
+	// maxCols, so it should end up padded to sit centered.
+	path := writeTestPNG(t, dir, "narrow.png", 10, 1000)
+
+	c := image.NewCache()
+	entry, _, err := c.Load(path, 100, 20)
+	require.NoError(t, err)
+
+	require.Less(t, entry.Cols, 100)
+	assert.Equal(t, (100-entry.Cols)/2, entry.PadLeft)
+}
+
+func TestCache_FillsBudgetGetsNoPadding(t *testing.T) {
+	dir := t.TempDir()
+	// A very wide image at a tight row budget: cols == maxCols, so it
+	// should need no centering.
+	path := writeTestPNG(t, dir, "wide2.png", 2000, 10)
+
+	c := image.NewCache()
+	entry, _, err := c.Load(path, 80, 5)
+	require.NoError(t, err)
+
+	assert.Equal(t, 80, entry.Cols)
+	assert.Equal(t, 0, entry.PadLeft)
+}
blob - 8618c9015f2c4bb7d7fc89fe1d28268b1b17c420
blob + 759d1a43a60d40455242080b54222905f85546e4
--- internal/image/image.go
+++ internal/image/image.go
@@ -49,15 +49,22 @@ func sentinel(index int) string {
 
 // Preprocess replaces each image reference whose cache entry is already
 // resolved (lookup returns ok) with a sentinel line, plus a caption
-// paragraph if present. References not yet resolved are left untouched, so
-// glamour falls back to its default "Image: alt -> src" rendering until
-// they become ready. The returned placements are indexed exactly as the
-// sentinels appear, for Expand to consume after markdown rendering.
-func Preprocess(markdown string, lookup func(Ref) (Entry, bool)) (string, []Placement) {
+// paragraph if present. References not yet resolved get loading (e.g. a
+// spinner frame) instead, plus the caption if any -- callers only reach
+// here once they already know graphics are supported, so "not ready" only
+// ever means "still decoding", never "never will". The returned
+// placements are indexed exactly as the sentinels appear, for Expand to
+// consume after markdown rendering.
+func Preprocess(markdown, loading string, lookup func(Ref) (Entry, bool)) (string, []Placement) {
 	var placements []Placement
 	for _, ref := range Parse(markdown) {
 		entry, ok := lookup(ref)
 		if !ok {
+			fallback := loading
+			if ref.Caption != "" {
+				fallback += "\n\n*" + ref.Caption + "*"
+			}
+			markdown = strings.Replace(markdown, ref.Raw, fallback, 1)
 			continue
 		}
 		replacement := sentinel(len(placements))
@@ -91,7 +98,10 @@ func Expand(rendered string, placements []Placement) s
 			continue
 		}
 		e := placements[idx].Entry
-		out = append(out, strings.Split(PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n")...)
+		pad := strings.Repeat(" ", e.PadLeft)
+		for _, row := range strings.Split(PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n") {
+			out = append(out, pad+row)
+		}
 	}
 	return strings.Join(out, "\n")
 }
blob - 04997a8b08fe05f30a6ed2cfe260b976c5e48dcc
blob + 4dbc31de257622abfebd49f7e29b7f9c98346aa2
--- internal/image/image_test.go
+++ internal/image/image_test.go
@@ -67,7 +67,7 @@ func TestPreprocessAndExpand(t *testing.T) {
 		return image.Entry{}, false
 	}
 
-	pre, placements := image.Preprocess(md, lookup)
+	pre, placements := image.Preprocess(md, "loading…", lookup)
 	require.Len(t, placements, 1)
 	assert.Equal(t, image.Entry{ID: 5, Cols: 10, Rows: 3}, placements[0].Entry)
 	assert.NotContains(t, pre, "![a chart]")
@@ -83,11 +83,20 @@ func TestPreprocessAndExpand(t *testing.T) {
 	assert.Equal(t, 3, placeholderLines)
 }
 
-func TestPreprocess_SkipsUnresolvedRefs(t *testing.T) {
+func TestPreprocess_UnresolvedRefsGetLoadingText(t *testing.T) {
 	md := "![missing](nope.png)"
-	pre, placements := image.Preprocess(md, func(image.Ref) (image.Entry, bool) {
+	pre, placements := image.Preprocess(md, "loading…", func(image.Ref) (image.Entry, bool) {
 		return image.Entry{}, false
 	})
-	assert.Equal(t, md, pre)
+	assert.Equal(t, "loading…", pre)
 	assert.Empty(t, placements)
 }
+
+func TestPreprocess_UnresolvedRefWithCaptionKeepsCaption(t *testing.T) {
+	md := `![missing](nope.png "Figure 1")`
+	pre, placements := image.Preprocess(md, "loading…", func(image.Ref) (image.Entry, bool) {
+		return image.Entry{}, false
+	})
+	assert.Equal(t, "loading…\n\n*Figure 1*", pre)
+	assert.Empty(t, placements)
+}
blob - 298690137181edee5643a890fff3089840f1606f
blob + a11be3e26ff329185d76470b836d0c3275d5c062
--- internal/latex/expand.go
+++ internal/latex/expand.go
@@ -22,10 +22,12 @@ func sentinel(i int) string {
 
 // Preprocess finds display ($$...$$) and inline ($...$) math in markdown.
 // Display blocks become a real image (sentinel + placement) when
-// graphicsOK and lookup resolves their cache entry; otherwise -- and
-// always for inline math, which never renders as an image -- they're
-// replaced with a Unicode approximation so they still read as text.
-func Preprocess(markdown string, dark, graphicsOK bool, lookup func(path string) (image.Entry, bool)) (string, []image.Placement) {
+// graphicsOK and lookup resolves their cache entry; when graphics are
+// supported but the entry isn't ready yet, they get loading (e.g. a
+// spinner frame) instead. Without graphics support at all -- and always
+// for inline math, which never renders as an image -- they're replaced
+// with a Unicode approximation so they still read as text.
+func Preprocess(markdown string, dark, graphicsOK bool, loading string, lookup func(path string) (image.Entry, bool)) (string, []image.Placement) {
 	display, masked := ParseDisplay(markdown)
 	inline := ParseInline(masked)
 
@@ -37,6 +39,8 @@ func Preprocess(markdown string, dark, graphicsOK bool
 				placements = append(placements, image.Placement{Entry: entry})
 				continue
 			}
+			markdown = strings.Replace(markdown, b.Raw, loading, 1)
+			continue
 		}
 		markdown = strings.Replace(markdown, b.Raw, ToUnicode(b.Source), 1)
 	}
@@ -66,7 +70,10 @@ func Expand(rendered string, placements []image.Placem
 			continue
 		}
 		e := placements[idx].Entry
-		out = append(out, strings.Split(image.PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n")...)
+		pad := strings.Repeat(" ", e.PadLeft)
+		for _, row := range strings.Split(image.PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n") {
+			out = append(out, pad+row)
+		}
 	}
 	return strings.Join(out, "\n")
 }
blob - 6d55e08e561435a80f77086f357ca39ffde6269e
blob + 98fc9f0ef4a44542c3805bfe377572045086f8cc
--- internal/latex/expand_test.go
+++ internal/latex/expand_test.go
@@ -18,25 +18,26 @@ func TestPreprocess_DisplayBecomesImage(t *testing.T) 
 		return image.Entry{ID: 3, Cols: 5, Rows: 2}, true
 	}
 
-	pre, placements := latex.Preprocess(md, true, true, lookup)
+	pre, placements := latex.Preprocess(md, true, true, "loading…", lookup)
 	require.Len(t, placements, 1)
 	assert.Equal(t, image.Entry{ID: 3, Cols: 5, Rows: 2}, placements[0].Entry)
 	assert.NotContains(t, pre, "$$")
 	assert.Contains(t, pre, "α")
 }
 
-func TestPreprocess_FallsBackToUnicodeWhenNotReady(t *testing.T) {
+func TestPreprocess_ShowsLoadingWhenGraphicsOKButNotReady(t *testing.T) {
 	md := "$$ x^2 $$"
-	pre, placements := latex.Preprocess(md, true, true, func(string) (image.Entry, bool) {
+	pre, placements := latex.Preprocess(md, true, true, "loading…", func(string) (image.Entry, bool) {
 		return image.Entry{}, false
 	})
 	assert.Empty(t, placements)
-	assert.Contains(t, pre, "x²")
+	assert.Contains(t, pre, "loading…")
+	assert.NotContains(t, pre, "x²", "should show the loading indicator, not fall through to unicode")
 }
 
 func TestPreprocess_FallsBackToUnicodeWithoutGraphics(t *testing.T) {
 	md := "$$ x^2 $$"
-	pre, placements := latex.Preprocess(md, true, false, func(string) (image.Entry, bool) {
+	pre, placements := latex.Preprocess(md, true, false, "loading…", func(string) (image.Entry, bool) {
 		t.Fatal("lookup should not be called when graphicsOK is false")
 		return image.Entry{}, false
 	})
@@ -46,7 +47,7 @@ func TestPreprocess_FallsBackToUnicodeWithoutGraphics(
 
 func TestExpand(t *testing.T) {
 	md := "$$ x^2 $$"
-	pre, placements := latex.Preprocess(md, true, true, func(string) (image.Entry, bool) {
+	pre, placements := latex.Preprocess(md, true, true, "loading…", func(string) (image.Entry, bool) {
 		return image.Entry{ID: 1, Cols: 4, Rows: 3}, true
 	})
 
blob - 7f296215e268d85bbfa1d18d93cb0636ae62fb0c
blob + b1e9d434686c65576176dc14ef4aafa9578116c5
--- internal/latex/unicode.go
+++ internal/latex/unicode.go
@@ -46,6 +46,28 @@ var symbols = map[string]rune{
 	"dots": '…', "ldots": '…', "cdots": '⋯', "vdots": '⋮', "ddots": '⋱',
 }
 
+// operatorNames are macros for named operators/functions (\sin, \log, ...)
+// that are just their name set upright in real LaTeX -- here, that means
+// dropping the backslash and keeping the letters as-is.
+var operatorNames = map[string]bool{
+	"sin": true, "cos": true, "tan": true, "cot": true, "sec": true, "csc": true,
+	"log": true, "ln": true, "exp": true, "lim": true, "sup": true, "inf": true,
+	"min": true, "max": true, "det": true, "gcd": true, "arg": true, "mod": true,
+	"deg": true, "dim": true, "ker": true, "hom": true,
+}
+
+// accentSuffix covers the physics notation combining marks can't:
+// combining-mark glyphs (arrow/hat/dot above) get silently dropped
+// somewhere in slides' own render pipeline (confirmed: they show up fine
+// printed directly to the terminal, but not once routed through
+// glamour/reflow), so these decorate their argument with an ordinary,
+// non-combining suffix character instead -- never as good-looking as a
+// mark sitting directly above the letter, but guaranteed to actually
+// show up.
+var accentSuffix = map[string]string{
+	"vec": "→", "hat": "^", "dot": "′", "ddot": "″",
+}
+
 // spacingCommands produce no visible output of their own.
 var spacingCommands = map[string]bool{
 	"left": true, "right": true, ",": true, "!": true, ";": true,
@@ -134,8 +156,20 @@ func writeMacro(out *strings.Builder, runes []rune, i 
 		a, next := readGroupOrChar(runes, skipSpace(runes, j))
 		out.WriteString("√(" + ToUnicode(a) + ")")
 		return next
+	case "bar", "overline":
+		// Physics' other standard notation for an average -- angle
+		// brackets -- doubles as the safe stand-in for the overline mark.
+		a, next := readGroupOrChar(runes, skipSpace(runes, j))
+		out.WriteString("⟨" + ToUnicode(a) + "⟩")
+		return next
 	}
 
+	if suffix, ok := accentSuffix[name]; ok {
+		a, next := readGroupOrChar(runes, skipSpace(runes, j))
+		out.WriteString(ToUnicode(a) + suffix)
+		return next
+	}
+
 	if spacingCommands[name] {
 		return j
 	}
@@ -143,6 +177,10 @@ func writeMacro(out *strings.Builder, runes []rune, i 
 		out.WriteRune(r)
 		return j
 	}
+	if operatorNames[name] {
+		out.WriteString(name)
+		return j
+	}
 	out.WriteString(string(runes[start:j]))
 	return j
 }
blob - 5c29f9bbedac1fc09bf4a7604fa1ca86629f8317
blob + 6b69b9238660b430f114d373aee0f565d1d739e3
--- internal/latex/unicode_test.go
+++ internal/latex/unicode_test.go
@@ -20,6 +20,8 @@ func TestToUnicode(t *testing.T) {
 		{"subscript digit", "x_1", "x₁"},
 		{"subscript group", "a_{ij}", "aᵢⱼ"},
 		{"unmapped superscript falls back to raw char", "x^q", "xq"},
+		{"operator name drops backslash", `\sin(x)`, "sin(x)"},
+		{"operator name in expression", `O(N \log N)`, "O(N log N)"},
 		{"frac simple", `\frac{a}{b}`, "(a)/(b)"},
 		{"frac nested macro", `\frac{\alpha}{2}`, "(α)/(2)"},
 		{"sqrt simple", `\sqrt{x}`, "√(x)"},
@@ -28,6 +30,12 @@ func TestToUnicode(t *testing.T) {
 		{"spacing commands dropped", `a\,b\;c\!d`, "abcd"},
 		{"left right dropped", `\left(x\right)`, "(x)"},
 		{"braces alone are stripped", "{x}", "x"},
+		{"vec accent", `\vec{F}`, "F→"},
+		{"hat accent", `\hat{n}`, "n^"},
+		{"dot accent, no braces", `\dot x`, "x′"},
+		{"ddot accent", `\ddot{x}`, "x″"},
+		{"bar accent", `\bar{v}`, "⟨v⟩"},
+		{"overline multi-letter", `\overline{AB}`, "⟨AB⟩"},
 	}
 
 	for _, tt := range tests {
blob - e53110565cfaf49477231a0f5efdf9b85a9b87b6
blob + eebe29b53bffdb5c59f459bb4faaea9a769ed017
--- internal/model/model.go
+++ internal/model/model.go
@@ -16,6 +16,7 @@ import (
 	"github.com/maaslalani/slides/internal/navigation"
 	"github.com/maaslalani/slides/internal/process"
 
+	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/viewport"
 	tea "charm.land/bubbletea/v2"
 	uv "github.com/charmbracelet/ultraviolet"
@@ -60,6 +61,7 @@ type Model struct {
 	images     *image.Cache
 	graphicsOK bool
 	darkBG     bool
+	spinner    spinner.Model
 }
 
 type fileWatchMsg struct{}
@@ -74,7 +76,7 @@ var fileInfo os.FileInfo
 // if it exists, and probes whether the terminal supports the Kitty
 // graphics protocol.
 func (m Model) Init() tea.Cmd {
-	cmds := []tea.Cmd{tea.Raw(image.ProbeSeq())}
+	cmds := []tea.Cmd{tea.Raw(image.ProbeSeq()), m.spinner.Tick}
 	if m.FileName != "" {
 		fileInfo, _ = os.Stat(m.FileName)
 		cmds = append(cmds, fileWatchCmd())
@@ -91,11 +93,65 @@ func (m *Model) imageBaseDir() string {
 	return filepath.Dir(m.FileName)
 }
 
+// presentation margin reserved on each side of the viewport, so slides
+// (text, images, everything) never run edge-to-edge.
+const hMarginFrac = 0.04
+const vMarginFrac = 0.03
+
+// contentSize returns the viewport size minus the presentation margin --
+// the width/height everything on the slide is laid out within.
+func (m *Model) contentSize() (width, height int) {
+	width = m.viewport.Width() - 2*int(float64(m.viewport.Width())*hMarginFrac)
+	height = m.viewport.Height() - 2*int(float64(m.viewport.Height())*vMarginFrac)
+	if width < 1 {
+		width = m.viewport.Width()
+	}
+	if height < 1 {
+		height = m.viewport.Height()
+	}
+	return width, height
+}
+
+// leftMargin is how many columns to indent the whole rendered slide by.
+// Since its content is contentSize()-wide, this centers it in the
+// viewport, giving an equal margin on both sides.
+func (m *Model) leftMargin() int {
+	contentWidth, _ := m.contentSize()
+	margin := (m.viewport.Width() - contentWidth) / 2
+	if margin < 0 {
+		return 0
+	}
+	return margin
+}
+
+// reservedRows is a fixed allowance for whatever text shares the slide
+// with a figure -- title, intro sentence, caption, status bar. A fixed
+// row count holds up better than a fraction of the viewport once the
+// viewport gets small (zooming in shrinks the cell grid), where a
+// percentage-based cut stops leaving enough room for fixed-size text.
+// It's still just a budget: a slide that also stacks a full table (or
+// more) alongside the figure can still run past it -- JoinVertical clips
+// the overflow so the status bar never drifts, but the figure itself may
+// get visually cut off. Splitting such a slide in two is the real fix.
+const reservedRows = 10
+
+// mediaSize returns the column/row budget for a single image or LaTeX
+// block: images can use the entire content width, while their row budget
+// leaves reservedRows for whatever else is on the slide.
+func (m *Model) mediaSize() (maxCols, maxRows int) {
+	contentWidth, contentHeight := m.contentSize()
+	maxRows = contentHeight - reservedRows
+	if maxRows < 3 {
+		maxRows = 3
+	}
+	return contentWidth, maxRows
+}
+
 // 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
 // inside a tea.Cmd.
-func loadImagesCmd(images *image.Cache, baseDir, slide string, maxCols int, darkBG bool) tea.Cmd {
+func loadImagesCmd(images *image.Cache, baseDir, slide string, maxCols, maxRows int, darkBG bool) tea.Cmd {
 	refs := image.Parse(slide)
 	display, _ := latex.ParseDisplay(slide)
 	if images == nil || (len(refs) == 0 && len(display) == 0) {
@@ -105,10 +161,10 @@ 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); ok {
+			if _, ok := images.Get(path, maxCols, maxRows); ok {
 				continue
 			}
-			_, seq, err := images.Load(path, maxCols)
+			_, seq, err := images.Load(path, maxCols, maxRows)
 			if err != nil || seq == "" {
 				continue
 			}
@@ -116,14 +172,14 @@ func loadImagesCmd(images *image.Cache, baseDir, slide
 		}
 		for _, b := range display {
 			cachePath := latex.CachePath(b.Source, darkBG)
-			if _, ok := images.Get(cachePath, maxCols); ok {
+			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)
+			_, seq, err := images.Load(renderedPath, maxCols, maxRows)
 			if err != nil || seq == "" {
 				continue
 			}
@@ -180,6 +236,7 @@ func (m *Model) Load() error {
 		// would race with Bubble Tea's input reader if called later from
 		// a tea.Cmd while the program is running.
 		m.darkBG = termenv.HasDarkBackground()
+		m.spinner = spinner.New(spinner.WithSpinner(spinner.Dot))
 	}
 	if m.images == nil {
 		m.images = image.NewCache()
@@ -202,7 +259,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 		if !m.graphicsOK || len(m.Slides) == 0 {
 			return m, nil
 		}
-		return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width(), m.darkBG)
+		maxCols, maxRows := m.mediaSize()
+		return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], maxCols, maxRows, m.darkBG)
 
 	case tea.KeyPressMsg:
 		keyPress := msg.String()
@@ -275,19 +333,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 			if !m.graphicsOK {
 				return m, nil
 			}
-			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width(), m.darkBG)
+			maxCols, maxRows := m.mediaSize()
+			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], maxCols, maxRows, m.darkBG)
 		}
 
 	case uv.KittyGraphicsEvent:
 		if msg.Options.ID == image.ProbeID && string(msg.Payload) == "OK" {
 			m.graphicsOK = true
-			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width(), m.darkBG)
+			maxCols, maxRows := m.mediaSize()
+			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], maxCols, maxRows, m.darkBG)
 		}
 		return m, nil
 
 	case imagesLoadedMsg:
 		return m, tea.Raw(strings.Join(msg.seqs, ""))
 
+	case spinner.TickMsg:
+		var cmd tea.Cmd
+		m.spinner, cmd = m.spinner.Update(msg)
+		return m, cmd
+
 	case fileWatchMsg:
 		newFileInfo, err := os.Stat(m.FileName)
 		if err == nil && newFileInfo.ModTime() != fileInfo.ModTime() {
@@ -305,23 +370,26 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 // View renders the current slide in the presentation and the status bar which
 // contains the author, date, and pagination information.
 func (m Model) View() tea.View {
-	r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(m.viewport.Width()))
+	contentWidth, _ := m.contentSize()
+	r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(contentWidth))
 	slide := m.Slides[m.Page]
 	slide = code.HideComments(slide)
 
+	maxCols, maxRows := m.mediaSize()
+	spin := m.spinner.View()
+
 	var placements []image.Placement
 	if m.graphicsOK && m.images != nil {
-		baseDir, maxCols := m.imageBaseDir(), m.viewport.Width()
-		slide, placements = image.Preprocess(slide, func(ref image.Ref) (image.Entry, bool) {
-			return m.images.Get(filepath.Join(baseDir, ref.Src), maxCols)
+		baseDir := m.imageBaseDir()
+		slide, placements = image.Preprocess(slide, spin+" cargando imagen…", func(ref image.Ref) (image.Entry, bool) {
+			return m.images.Get(filepath.Join(baseDir, ref.Src), maxCols, maxRows)
 		})
 	}
 
 	var latexPlacements []image.Placement
 	if m.images != nil {
-		maxCols := m.viewport.Width()
-		slide, latexPlacements = latex.Preprocess(slide, m.darkBG, m.graphicsOK, func(path string) (image.Entry, bool) {
-			return m.images.Get(path, maxCols)
+		slide, latexPlacements = latex.Preprocess(slide, m.darkBG, m.graphicsOK, spin+" renderizando fórmula…", func(path string) (image.Entry, bool) {
+			return m.images.Get(path, maxCols, maxRows)
 		})
 	}
 
@@ -338,6 +406,14 @@ func (m Model) View() tea.View {
 	if len(latexPlacements) > 0 {
 		slide = latex.Expand(slide, latexPlacements)
 	}
+	if margin := m.leftMargin(); margin > 0 {
+		pad := strings.Repeat(" ", margin)
+		lines := strings.Split(slide, "\n")
+		for i := range lines {
+			lines[i] = pad + lines[i]
+		}
+		slide = strings.Join(lines, "\n")
+	}
 
 	var left string
 	if m.Search.Active {
blob - d83a5c94b3e3375d6f1fa0983b002219f4608105
blob + bba8a9eb08582a14e32b58443ce217d040f58c29
--- styles/styles.go
+++ styles/styles.go
@@ -50,10 +50,36 @@ func JoinHorizontal(left, right string, width int) str
 	return lipgloss.PlaceHorizontal(w, lipgloss.Left, left) + right
 }
 
-// JoinVertical joins two strings vertically and fills the space in-between.
+// JoinVertical joins two strings vertically and fills the space in-between,
+// centering top within it rather than pinning it to the first line -- on
+// a tall terminal, a short slide otherwise reads as stranded at the top
+// with a large dead gap below it.
+//
+// top is always clipped to at most height-lipgloss.Height(bottom) lines:
+// PlaceVertical is a no-op when its content is already taller than the
+// requested height, so without this, bottom (the status bar) would drift
+// away from the last line whenever top overflows -- which becomes more
+// likely the smaller the viewport gets, e.g. after zooming in.
+//
+// The concatenation below relies on top's own last line being blank: with
+// no room left to spare, that's where bottom's leading (padding) line
+// merges in. When clipping kicks in, the last surviving line is real
+// content instead, so it's swapped for an explicit blank one to keep
+// that seam clean rather than smushing text into the status bar.
 func JoinVertical(top, bottom string, height int) string {
 	h := height - lipgloss.Height(bottom)
-	return lipgloss.PlaceVertical(h, lipgloss.Top, top) + bottom
+	if h < 0 {
+		h = 0
+	}
+	placed := lipgloss.PlaceVertical(h, lipgloss.Center, top)
+	if lines := strings.Split(placed, "\n"); len(lines) > h {
+		keep := h - 1
+		if keep < 0 {
+			keep = 0
+		}
+		placed = strings.Join(append(lines[:keep], ""), "\n")
+	}
+	return placed + bottom
 }
 
 // SelectTheme picks a glamour style config based
blob - 94dd31cd9802a407f1c56aa10fb008c2aa6eca5d
blob + b898a0926f9fbc7852e8153fc722a2f1e4b589e7
--- styles/styles_test.go
+++ styles/styles_test.go
@@ -1,6 +1,7 @@
 package styles_test
 
 import (
+	"strings"
 	"testing"
 
 	"github.com/charmbracelet/glamour"
@@ -10,6 +11,41 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestJoinVertical_ZeroHeightDoesNotPanic(t *testing.T) {
+	// Regression: the very first render can happen before the initial
+	// tea.WindowSizeMsg arrives, with height still 0 -- must not panic.
+	assert.NotPanics(t, func() {
+		styles.JoinVertical("slide content\nmore lines", "status", 0)
+	})
+}
+
+func TestJoinVertical_BottomTallerThanHeightDoesNotPanic(t *testing.T) {
+	assert.NotPanics(t, func() {
+		styles.JoinVertical("top", "line1\nline2\nline3", 1)
+	})
+}
+
+func TestJoinVertical_ClipsOverflowingTop(t *testing.T) {
+	top := "1\n2\n3\n4\n5"
+	got := styles.JoinVertical(top, "status", 3)
+	// height=3, bottom is 1 line -> top's budget is 2 lines; the last of
+	// those is swapped for a blank one so bottom merges into it cleanly
+	// instead of appending onto real content ("2status").
+	assert.Equal(t, "1\nstatus", got)
+}
+
+func TestJoinVertical_ClipsOverflowingTop_PaddedBottomStaysClean(t *testing.T) {
+	// bottom shaped like the real status bar: Padding(1) gives it a
+	// blank line above and below its actual content.
+	top := "1\n2\n3\n4\n5"
+	bottom := " \nSTATUS\n "
+	got := styles.JoinVertical(top, bottom, 5)
+	assert.Equal(t, "1\n \nSTATUS\n ", got)
+	for _, line := range strings.Split(got, "\n") {
+		assert.NotContains(t, line, "2STATUS", "clipped content must not merge into status text")
+	}
+}
+
 func TestSelectTheme(t *testing.T) {
 	tests := []struct {
 		name    string
blob - ab3c9c82716573bec2e5d1d88aa16ee458aaf071
blob + 60807cf71b3ee05c1e174b5f885a67f60c43f290
--- styles/theme.json
+++ styles/theme.json
@@ -19,14 +19,16 @@
     "bold": true
   },
   "h1": {
-    "prefix": "██ ",
-    "suffix": " ",
-    "color": "#9fc",
+    "prefix": "  ",
+    "suffix": "  ",
+    "color": "#111",
+    "background_color": "#9fc",
     "bold": true
   },
   "h2": {
     "prefix": "▓▓▓ ",
-    "color": "#1cc"
+    "color": "#1cc",
+    "bold": true
   },
   "h3": {
     "prefix": "▒▒▒▒ ",