▍ humdrum codex / glint v1.0.2

feat: paint theme background across the editor canvas

d919368def14186eddf050666eba51e0c64e5d7d
humdrum <me@humdrum.me> · 2026-06-28 14:32

parent fc11c139

feat: paint theme background across the editor canvas

Themes defined a Background color but nothing painted it, so dark/charm
themes showed their foreground colors over the terminal's own (often light)
background. Stamp theme.Background onto every editor span in buildVisual and
fill the canvas margins, top pad, and right gap to the full terminal width
with the theme background (paintCanvas), replacing the plain-space indent.
The whole editor/preview frame is now the theme's paper. Tests: spans carry
the background; every rendered line spans the full width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed

internal/app/app.go +21 −22
@@ -267,22 +267,6 @@ }
 
 func (a *App) topPad() int { return canvasTopPad }
 
-// indentLines prefixes each non-empty line with n spaces (blank rows stay blank,
-// so the trailing newline of a sub-view does not become a spaces-only row).
-func indentLines(s string, n int) string {
-	if n <= 0 {
-		return s
-	}
-	pad := strings.Repeat(" ", n)
-	lines := strings.Split(s, "\n")
-	for i, ln := range lines {
-		if ln != "" {
-			lines[i] = pad + ln
-		}
-	}
-	return strings.Join(lines, "\n")
-}
-
 func (a *App) setSize(w, h int) {
 	a.width = w
 	a.height = h
@@ -370,14 +354,29 @@ 		body = a.preview.View()
 	} else {
 		body = a.editor.View()
 	}
-	body = indentLines(body, a.leftMargin())
-	if a.topPad() > 0 {
-		body = strings.Repeat("\n", a.topPad()) + body
+	return a.paintCanvas(body) + a.statusBar()
+}
+
+// paintCanvas centers body in the theme's paper: a top pad, then each body line
+// indented by the left margin, every line filled to the full terminal width with
+// the theme background so margins and empty space carry the theme color rather
+// than the terminal default. The result is exactly height-1 rows (the status bar
+// is the final row) and always ends with a trailing newline before it.
+func (a *App) paintCanvas(body string) string {
+	bg := lipgloss.NewStyle().Background(a.theme.Background).Width(maxInt(a.width, 1))
+	lm := strings.Repeat(" ", a.leftMargin())
+	rows := make([]string, 0, a.height)
+	for p := 0; p < a.topPad(); p++ {
+		rows = append(rows, bg.Render(""))
 	}
-	if !strings.HasSuffix(body, "\n") {
-		body += "\n"
+	for _, ln := range strings.Split(body, "\n") {
+		if ln == "" {
+			rows = append(rows, bg.Render(""))
+		} else {
+			rows = append(rows, bg.Render(lm+ln))
+		}
 	}
-	return body + a.statusBar()
+	return strings.Join(rows, "\n") + "\n"
 }
 
 func (a *App) statusBar() string {
internal/app/app_test.go +16 −0
@@ -7,9 +7,25 @@ 	"strings"
 	"testing"
 
 	"glint/internal/config"
+	"glint/internal/theme"
 
 	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
 )
+
+func TestCanvasPaintsFullWidthBackground(t *testing.T) {
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	a.editor.SetContent([]byte("hi"))
+	out := a.View()
+	for i, ln := range strings.Split(out, "\n") {
+		if w := lipgloss.Width(ln); w != 100 {
+			t.Errorf("line %d visible width = %d, want 100 (full-width background fill)", i, w)
+		}
+	}
+}
 
 func newApp() *App {
 	return New(config.Default())
internal/editor/editor_test.go +17 −0
@@ -3,6 +3,8 @@
 import (
 	"testing"
 
+	"glint/internal/theme"
+
 	tea "github.com/charmbracelet/bubbletea"
 )
 
@@ -271,3 +273,18 @@ 	if ci < e.Scroll || ci >= e.Scroll+e.Height {
 		t.Errorf("cursor visual row %d not in viewport [%d,%d)", ci, e.Scroll, e.Scroll+e.Height)
 	}
 }
+
+func TestBuildVisualSpansCarryThemeBackground(t *testing.T) {
+	e := New()
+	th := theme.FlexokiDark()
+	e.SetTheme(th)
+	e.Lines = []string{"# Heading **bold**", "plain text", ""}
+	for _, vr := range e.buildVisual() {
+		for _, sp := range vr.spans {
+			if sp.Style.GetBackground() != th.Background {
+				t.Errorf("span %q background = %v, want theme background %v",
+					sp.Text, sp.Style.GetBackground(), th.Background)
+			}
+		}
+	}
+}
internal/editor/wrap.go +14 −2
@@ -1,5 +1,7 @@
 package editor
 
+import "github.com/charmbracelet/lipgloss"
+
 // segment is one visual row of a logical line: a contiguous rune slice plus the
 // rune offset (start column) within the logical line where it begins.
 type segment struct {
@@ -74,7 +76,8 @@ 	spans  []Span
 }
 
 // buildVisual wraps every logical line into visual rows, slicing each line's
-// styled spans to the segment ranges.
+// styled spans to the segment ranges. Every span is given the theme background
+// so glyphs sit on the theme's paper rather than the terminal default.
 func (e *Editor) buildVisual() []vrow {
 	all := ScanLines(e.Lines, e.theme)
 	var rows []vrow
@@ -85,11 +88,20 @@ 			rows = append(rows, vrow{
 				logRow: li,
 				start:  s.start,
 				runes:  n,
-				spans:  sliceSpans(all[li], s.start, s.start+n),
+				spans:  withBackground(sliceSpans(all[li], s.start, s.start+n), e.theme.Background),
 			})
 		}
 	}
 	return rows
+}
+
+// withBackground stamps the theme background onto every span's style so the
+// rendered text has no terminal-default gaps between or under glyphs.
+func withBackground(spans []Span, bg lipgloss.Color) []Span {
+	for i := range spans {
+		spans[i].Style = spans[i].Style.Background(bg)
+	}
+	return spans
 }
 
 // cursorVIndex returns the index of the visual row holding the cursor. A cursor