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)
+ }
+}