commit 1686f28a01bc9570c6a1149302570e0f37f14171 from: ale date: Fri Jul 31 06:57:13 2026 UTC Polish layout, sizing, and physics notation for projector use - Compute image/formula budget and margins from real viewport size instead of hard-coded columns/rows, and center content that's narrower than the available width. - Keep the status bar pinned to the bottom and stop content from overflowing the margin when the terminal is resized/zoomed. - Show a spinner while images/formulas are still loading instead of a blank gap. - Add physics vector/derivative notation (vec, hat, dot, ddot, bar) to the inline LaTeX-to-Unicode converter, and named operators (sin, log, lim, ...); non-combining glyphs are used since combining marks get silently dropped somewhere in the render pipeline. - Graduated heading styles (bold/banner h1, bold h2) for visual hierarchy since terminal font size itself isn't controllable from the app. 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": "▒▒▒▒ ",