commit - 372f60546819d91aaf4368cb7ea2b8a980156bfc
commit + 1686f28a01bc9570c6a1149302570e0f37f14171
blob - 5e48cfac7a6d71ae8cbc008d80be16e8c108740a
blob + b5fd7cedd7d4b9f52b460685e9fd9454a5844acf
--- internal/image/cache.go
+++ internal/image/cache.go
// 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
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
}
// 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
}
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)
}
// 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
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
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) {
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)
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
// 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))
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
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]")
assert.Equal(t, 3, placeholderLines)
}
-func TestPreprocess_SkipsUnresolvedRefs(t *testing.T) {
+func TestPreprocess_UnresolvedRefsGetLoadingText(t *testing.T) {
md := ""
- 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 := ``
+ 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
// 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)
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)
}
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
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
})
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
"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,
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
}
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
{"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)"},
{"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
"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"
images *image.Cache
graphicsOK bool
darkBG bool
+ spinner spinner.Model
}
type fileWatchMsg struct{}
// 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())
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) {
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
}
}
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
}
// 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()
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()
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() {
// 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)
})
}
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
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
package styles_test
import (
+ "strings"
"testing"
"github.com/charmbracelet/glamour"
"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
"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": "▒▒▒▒ ",