Commit Diff


commit - 17219495f34a017ba2918517de54d8c7bf15a888
commit + 502e0d5bb61287accc4380aa23b380a8929faf1b
blob - dad863b30ac32f2fd1a3392e2953e7f5b9cf1aaa
blob + df3e4fec2c678e864b42e6ab4ee50929f564c3b0
--- internal/model/model.go
+++ internal/model/model.go
@@ -25,6 +25,7 @@ import (
 
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/glamour"
+	"github.com/charmbracelet/x/ansi/kitty"
 	"github.com/maaslalani/slides/internal/bib"
 	"github.com/maaslalani/slides/internal/code"
 	"github.com/maaslalani/slides/internal/image"
@@ -332,6 +333,29 @@ func juliaEnv(maxCols, maxRows int) map[string]string 
 	}
 }
 
+// centerMarker on its own line makes renderSlide horizontally center the
+// slide's text.
+const centerMarker = "<!-- center -->"
+
+// centerLines horizontally centers every non-blank text line of s within
+// width. Lines carrying a Kitty graphics placeholder are left untouched --
+// their own PadLeft already centers the image.
+func centerLines(s string, width int) string {
+	lines := strings.Split(s, "\n")
+	for i, line := range lines {
+		if strings.ContainsRune(line, kitty.Placeholder) {
+			continue
+		}
+		trimmed := strings.TrimSpace(line)
+		w := lipgloss.Width(trimmed)
+		if w == 0 || w >= width {
+			continue
+		}
+		lines[i] = strings.Repeat(" ", (width-w)/2) + trimmed
+	}
+	return strings.Join(lines, "\n")
+}
+
 func fileWatchCmd() tea.Cmd {
 	return tea.Every(time.Second, func(t time.Time) tea.Msg {
 		return fileWatchMsg{}
@@ -579,6 +603,15 @@ func (m Model) renderSlide(md string, includeVirtualTe
 	r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(contentWidth))
 	slide := code.HideComments(md)
 
+	// A slide carrying `<!-- center -->` on its own line has every text
+	// line horizontally centered after rendering (images keep their own
+	// centering). Strip the marker before glamour sees it.
+	centered := strings.Contains(slide, centerMarker)
+	if centered {
+		slide = strings.ReplaceAll(slide, centerMarker+"\n", "")
+		slide = strings.ReplaceAll(slide, centerMarker, "")
+	}
+
 	maxCols, maxRows := m.mediaSize()
 	spin := m.spinner.View()
 
@@ -633,6 +666,9 @@ func (m Model) renderSlide(md string, includeVirtualTe
 	if len(latexPlacements) > 0 {
 		slide = latex.Expand(slide, latexPlacements)
 	}
+	if centered {
+		slide = centerLines(slide, contentWidth)
+	}
 	if margin := m.leftMargin(); margin > 0 {
 		pad := strings.Repeat(" ", margin)
 		lines := strings.Split(slide, "\n")
blob - /dev/null
blob + 3f4d280872cee04739c4627f646e73179d20fb07 (mode 644)
--- /dev/null
+++ internal/model/center_test.go
@@ -0,0 +1,43 @@
+package model
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/x/ansi/kitty"
+)
+
+func TestCenterLines(t *testing.T) {
+	const width = 20
+
+	got := centerLines("hello\n\n  spaced  ", width)
+	lines := strings.Split(got, "\n")
+	if lines[0] != strings.Repeat(" ", 7)+"hello" { // (20-5)/2 = 7
+		t.Errorf("line 0 not centered: %q", lines[0])
+	}
+	if lines[1] != "" {
+		t.Errorf("blank line changed: %q", lines[1])
+	}
+	if lines[2] != strings.Repeat(" ", 7)+"spaced" { // trimmed len 6, (20-6)/2 = 7
+		t.Errorf("line 2 not centered/trimmed: %q", lines[2])
+	}
+}
+
+func TestCenterLinesLeavesPlaceholdersAndAnsi(t *testing.T) {
+	ph := string(kitty.Placeholder) + "xxxx"
+	got := centerLines(ph, 40)
+	if got != ph {
+		t.Errorf("placeholder line was modified: %q", got)
+	}
+
+	ansi := "\x1b[1mbold\x1b[0m" // visible width 4
+	got = centerLines(ansi, 10)
+	if got != strings.Repeat(" ", 3)+ansi { // (10-4)/2 = 3
+		t.Errorf("ansi width miscounted: %q", got)
+	}
+
+	long := "this line is wider than the width"
+	if centerLines(long, 10) != long {
+		t.Errorf("over-wide line should be untouched")
+	}
+}