▍ humdrum codex / glint v1.0.2

feat: centered, padded writing canvas for editor and preview

2347d8e88b92fa9fae25ac97df3361ccecd10c30
humdrum <me@humdrum.me> · 2026-06-28 11:24

parent 2f944584

2 files changed

internal/app/app.go +76 −9
@@ -4,6 +4,7 @@ package app
 
 import (
 	"fmt"
+	"math"
 	"os"
 	"path/filepath"
 	"strings"
@@ -35,6 +36,14 @@ const (
 	discardNone   pendingDiscard = iota
 	discardPicker
 	discardDaily
+)
+
+// Canvas layout: a centered, percentage-width text column with a little top air.
+const (
+	canvasRatio  = 0.65
+	canvasMax    = 100
+	canvasMin    = 24
+	canvasTopPad = 1
 )
 
 // App is the root model.
@@ -231,13 +240,61 @@ 	a.mode = ModePreview
 	return a, nil
 }
 
+// contentWidth is the centered text column width: ~65% of the terminal, capped
+// for readability and floored so it never collapses, never wider than the term.
+func (a *App) contentWidth() int {
+	w := int(math.Round(float64(a.width) * canvasRatio))
+	if w > canvasMax {
+		w = canvasMax
+	}
+	if w < canvasMin {
+		w = canvasMin
+	}
+	if w > a.width {
+		w = a.width
+	}
+	return w
+}
+
+// leftMargin centers the content column in the terminal.
+func (a *App) leftMargin() int {
+	m := (a.width - a.contentWidth()) / 2
+	if m < 0 {
+		m = 0
+	}
+	return m
+}
+
+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
-	a.editor.SetSize(w, h-1) // reserve one row for the status bar
-	a.preview.SetSize(w, h-1)
+	cw := a.contentWidth()
+	textRows := h - 1 - a.topPad() // status bar + top pad
+	if textRows < 1 {
+		textRows = 1
+	}
+	a.editor.SetSize(cw, textRows)
+	a.preview.SetSize(cw, textRows)
 	if a.picker != nil {
-		a.picker.SetSize(w, h-1)
+		a.picker.SetSize(w, h-1) // picker keeps its full-width split
 	}
 }
 
@@ -296,16 +353,26 @@ 	}
 	return a, nil
 }
 
-// View renders the active sub-view plus the status bar.
+// View renders the active sub-view. The editor and preview sit in a centered,
+// padded column; the picker keeps its own full-width layout.
 func (a *App) View() string {
+	if a.mode == ModePicker {
+		body := a.picker.View()
+		if !strings.HasSuffix(body, "\n") {
+			body += "\n"
+		}
+		return body + a.statusBar()
+	}
+
 	var body string
-	switch a.mode {
-	case ModePreview:
+	if a.mode == ModePreview {
 		body = a.preview.View()
-	case ModePicker:
-		body = a.picker.View()
-	default:
+	} else {
 		body = a.editor.View()
+	}
+	body = indentLines(body, a.leftMargin())
+	if a.topPad() > 0 {
+		body = strings.Repeat("\n", a.topPad()) + body
 	}
 	if !strings.HasSuffix(body, "\n") {
 		body += "\n"
internal/app/app_test.go +45 −4
@@ -86,11 +86,11 @@
 func TestWindowSizePropagates(t *testing.T) {
 	a := newApp()
 	a.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
-	if a.editor.Width != 100 {
-		t.Errorf("editor width = %d, want 100", a.editor.Width)
+	if a.editor.Width != 65 { // contentWidth = round(100 * 0.65) = 65
+		t.Errorf("editor width = %d, want 65", a.editor.Width)
 	}
-	if a.editor.Height != 29 { // minus status bar
-		t.Errorf("editor height = %d, want 29", a.editor.Height)
+	if a.editor.Height != 28 { // minus status bar and top pad
+		t.Errorf("editor height = %d, want 28", a.editor.Height)
 	}
 }
 
@@ -339,3 +339,44 @@ 	if _, err := os.Stat(want); err != nil {
 		t.Errorf("new note should exist on disk: %v", err)
 	}
 }
+
+func TestCanvasContentWidthCapsAndCenters(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 200, Height: 40})
+	if cw := a.contentWidth(); cw != 100 {
+		t.Errorf("contentWidth = %d, want 100 (capped)", cw)
+	}
+	if lm := a.leftMargin(); lm != (200-100)/2 {
+		t.Errorf("leftMargin = %d, want %d", lm, (200-100)/2)
+	}
+}
+
+func TestCanvasContentWidthFloorsOnNarrow(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 30, Height: 20})
+	if cw := a.contentWidth(); cw != 24 {
+		t.Errorf("contentWidth = %d, want 24 (floor)", cw)
+	}
+}
+
+func TestCanvasEditorViewIsIndented(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 120, Height: 20})
+	a.editor.SetContent([]byte("hello"))
+	view := a.View()
+	// The editor body sits in a centered column → at least one rendered line
+	// starts with the left-margin spaces.
+	if a.leftMargin() <= 0 {
+		t.Fatal("expected a positive left margin at width 120")
+	}
+	pad := strings.Repeat(" ", a.leftMargin())
+	found := false
+	for _, ln := range strings.Split(view, "\n") {
+		if strings.HasPrefix(ln, pad) && strings.Contains(ln, "hello") {
+			found = true
+		}
+	}
+	if !found {
+		t.Errorf("editor content not indented by left margin; view:\n%s", view)
+	}
+}