fix: keyboard shortcut for line and doc navigation.
25666a37f4e4d0b4fc8be0e1f1d27e0afefc4d78
Kevin Kortum <kevinkortum@me.com> · 2026-07-09 12:11
parent af905318
12 files changed
README.md +39 −0
@@ -57,6 +57,7 @@ | mouse click | move the cursor |
| mouse drag | select text (press to anchor, drag to extend, release to keep) |
| mouse wheel | scroll the view |
| `Home` / `End` | smart Home (first non-blank, then column 0) / end of line |
+| `Ctrl+Home` / `Ctrl+End` | top / bottom of the document (`Ctrl+↑` / `Ctrl+↓` do the same, but macOS binds those to Mission Control — see below) |
| type `(` `[` `` ` `` with no selection | auto-closes the pair with the cursor inside; type the closer over it to step past |
| `Shift`+arrows · `Shift+Home/End` | select text (`Ctrl+Shift+←/→` selects by word) |
| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard); pasting a URL over a selection makes a `[selection](url)` link |
@@ -80,6 +81,44 @@ | `Alt+;` · click | spellcheck popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `t` toggle spellcheck, `Esc` close |
| `Ctrl+/` | toggle the in-editor help overlay (keys + commands) |
| `Ctrl+Q` | quit (press twice if there are unsaved changes) |
| `Esc` | clear the selection, or close find / back to the editor |
+
+### Using Cmd+arrows on macOS
+
+Terminals speak ANSI escape sequences, which have no concept of a Cmd (Super)
+key — a terminal app never sees `Cmd+←`, only whatever bytes the terminal chose
+to send for it. glint therefore can't bind Cmd itself; your **terminal** has to
+translate. Every key in the table above works out of the box with no config.
+
+To get the macOS muscle memory (`Cmd+←/→` for line edges, `Cmd+↑/↓` for document
+edges), map the four sequences glint listens for:
+
+| Cmd key | Sequence to send | glint sees |
+| --- | --- | --- |
+| `Cmd+←` | `ESC [ H` | `Home` |
+| `Cmd+→` | `ESC [ F` | `End` |
+| `Cmd+↑` | `ESC [ 1;5H` | `Ctrl+Home` |
+| `Cmd+↓` | `ESC [ 1;5F` | `Ctrl+End` |
+
+**Ghostty** — in `~/.config/ghostty/config`:
+
+```
+keybind = cmd+left=csi:H
+keybind = cmd+right=csi:F
+keybind = cmd+up=csi:1;5H
+keybind = cmd+down=csi:1;5F
+```
+
+**iTerm2** — Settings → Profiles → Keys → Key Mappings, add each as *Send Escape
+Sequence* with the text after the `ESC` (`[H`, `[F`, `[1;5H`, `[1;5F`).
+
+**Apple Terminal** — Settings → Profiles → Keyboard → `+`, same escape sequences.
+
+On a Mac, `Ctrl+↑` and `Ctrl+↓` never reach any terminal app — macOS claims them
+for Mission Control (System Settings → Keyboard → Keyboard Shortcuts → Mission
+Control). Use `Ctrl+Home`/`Ctrl+End`, or the `Cmd+↑`/`Cmd+↓` mapping above.
+
+Unsure what your terminal actually sends? `glint --keys` prints the key glint
+decodes for each press — press `Cmd+↑` and see whether anything arrives.
## Configuration
- → Preview-doc-name-H1-bar-tight-styled-properties.md +27 −0
@@ -0,0 +1,27 @@
+---
+id: TASK-040
+title: 'Preview: doc-name H1 bar + tight styled properties'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-09 04:36'
+updated_date: '2026-07-09 04:37'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 39000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Preview rendered YAML frontmatter as junk (--- rules, mashed key/value paragraph, stray bullets). Now: frontmatter is peeled off before glamour; doc name (frontmatter title, else filename) renders as a full-width bold bar in the heading color; properties render tight (no blank lines, no rules) with dim aligned keys and italic values.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Frontmatter --- fences never render as horizontal rules
+- [ ] #2 Doc name shows as a full-width color-block H1 bar
+- [ ] #3 Frontmatter title overrides the filename and is not repeated as a property
+- [ ] #4 Properties are consecutive lines, aligned, dim keys, italic values
+<!-- AC:END -->
- → Swap-Home-End-and-CtrlUp-Down-line-edges-vs-document-edges.md +30 −0
@@ -0,0 +1,30 @@
+---
+id: TASK-041
+title: 'Swap Home/End and Ctrl+Up/Down: line edges vs document edges'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-09 04:42'
+updated_date: '2026-07-09 05:13'
+labels:
+ - bug
+dependencies: []
+priority: medium
+ordinal: 40000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Cmd+Left/Right (arriving as Home/End) jumped to the top/bottom of the document, and Ctrl+Up/Down moved to line start/end — backwards from macOS convention. Home/End now move to line edges; Ctrl+Home/Ctrl+End (what Ghostty's cmd+up/cmd+down send: CSI 1;5H / CSI 1;5F) move to document edges, with Ctrl+Up/Ctrl+Down as aliases. Shift variants follow. Bubbletea's Key struct has no Cmd/Super modifier, so Cmd can only ever reach glint as a terminal-emitted escape sequence — README now documents the four sequences plus Ghostty/iTerm2/Terminal.app config.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 Home/End move to line start (smart, toggling first non-blank / column 0) and line end
+- [ ] #2 Ctrl+Up/Ctrl+Down move to document start/end
+- [ ] #3 Shift+Home/Shift+End select within the line
+- [ ] #4 Ctrl+Shift+Up/Down select to the document edges
+- [ ] #5 Help text reflects the new mapping
+- [ ] #6 Ctrl+Home/Ctrl+End move to document start/end
+- [ ] #7 README documents the terminal config needed for Cmd+arrows
+<!-- AC:END -->
internal/app/app.go +17 −3
@@ -142,7 +142,21 @@ Text: string(th.Text),
Heading: string(th.Heading),
Code: string(th.Code),
Link: string(th.Link),
+ Muted: string(th.Muted),
}
+}
+
+// renderPreview re-renders the read view from the current buffer, naming the
+// document after its file (a frontmatter "title" wins over the filename).
+// Untitled buffers get no name bar.
+func (a *App) renderPreview() error {
+ name := ""
+ if a.path != "" {
+ base := filepath.Base(a.path)
+ name = strings.TrimSuffix(base, filepath.Ext(base))
+ }
+ a.preview.SetTitle(name)
+ return a.preview.Render(string(a.editor.Bytes()))
}
// Load reads a file into the editor and switches to edit mode.
@@ -798,7 +812,7 @@ a.preview.SetColors(previewColors(a.theme))
// Re-render an open preview so its glamour style follows the new theme
// (otherwise it keeps the old light/dark block until the next toggle).
if a.mode == ModePreview {
- _ = a.preview.Render(string(a.editor.Bytes()))
+ _ = a.renderPreview()
}
if a.picker != nil {
a.picker.SetTheme(a.theme)
@@ -832,7 +846,7 @@ if a.mode == ModePreview {
a.mode = ModeEditor
return a, tea.EnableMouseCellMotion
}
- if err := a.preview.Render(string(a.editor.Bytes())); err != nil {
+ if err := a.renderPreview(); err != nil {
a.status = "Preview failed: " + err.Error()
return a, nil
}
@@ -886,7 +900,7 @@ if a.picker != nil {
a.picker.SetSize(w, h-1) // picker keeps its full-width split
}
if a.mode == ModePreview {
- _ = a.preview.Render(string(a.editor.Bytes())) // re-wrap at the new width
+ _ = a.renderPreview() // re-wrap at the new width
}
}
internal/editor/editor.go +15 −10
@@ -729,16 +729,21 @@ return
}
e.ClearSelection()
e.MoveDown()
- case tea.KeyCtrlUp:
+ // Line edges. A terminal that maps Cmd+Left/Right to CSI H / CSI F (see the
+ // README) delivers them here.
+ case tea.KeyHome:
e.ClearSelection()
e.MoveHome()
- case tea.KeyCtrlDown:
+ case tea.KeyEnd:
e.ClearSelection()
e.MoveEnd()
- case tea.KeyHome:
+ // Document edges. Ctrl+Home/Ctrl+End is the cross-platform convention; a
+ // terminal mapping Cmd+Up/Down to CSI 1;5H / CSI 1;5F lands here too. Ctrl+Up
+ // and Ctrl+Down are aliases for keyboards without Home/End.
+ case tea.KeyCtrlHome, tea.KeyCtrlUp:
e.ClearSelection()
e.MoveDocStart()
- case tea.KeyEnd:
+ case tea.KeyCtrlEnd, tea.KeyCtrlDown:
e.ClearSelection()
e.MoveDocEnd()
@@ -757,22 +762,22 @@ e.startSelection()
e.MoveDown()
case tea.KeyShiftHome:
e.startSelection()
- e.MoveDocStart()
+ e.MoveHome()
case tea.KeyShiftEnd:
e.startSelection()
- e.MoveDocEnd()
+ e.MoveEnd()
case tea.KeyCtrlShiftLeft:
e.startSelection()
e.MoveWordLeft()
case tea.KeyCtrlShiftRight:
e.startSelection()
e.MoveWordRight()
- case tea.KeyCtrlShiftUp:
+ case tea.KeyCtrlShiftHome, tea.KeyCtrlShiftUp:
e.startSelection()
- e.MoveHome()
- case tea.KeyCtrlShiftDown:
+ e.MoveDocStart()
+ case tea.KeyCtrlShiftEnd, tea.KeyCtrlShiftDown:
e.startSelection()
- e.MoveEnd()
+ e.MoveDocEnd()
}
}
internal/editor/editor_test.go +88 −18
@@ -413,33 +413,103 @@ t.Errorf("Alt+f → col %d, want 16 (end of gamma)", e.Cursor.Col)
}
}
+// Ctrl+Home/Ctrl+End (and the Ctrl+Up/Ctrl+Down aliases) jump to the top and
+// bottom of the document.
func TestDocStartEnd(t *testing.T) {
- e := newEditorWith("first", "middle", "last line")
- e.Cursor = Position{Row: 1, Col: 2}
+ for _, tc := range []struct {
+ name string
+ start, end tea.KeyType
+ }{
+ {"ctrl+home/ctrl+end", tea.KeyCtrlHome, tea.KeyCtrlEnd},
+ {"ctrl+up/ctrl+down", tea.KeyCtrlUp, tea.KeyCtrlDown},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ e := newEditorWith("first", "middle", "last line")
+ e.Cursor = Position{Row: 1, Col: 2}
+ e.HandleKey(tea.KeyMsg{Type: tc.end})
+ if e.Cursor != (Position{Row: 2, Col: 9}) {
+ t.Errorf("end key → %+v, want {2 9} (end of document)", e.Cursor)
+ }
+ e.HandleKey(tea.KeyMsg{Type: tc.start})
+ if e.Cursor != (Position{Row: 0, Col: 0}) {
+ t.Errorf("start key → %+v, want {0 0} (start of document)", e.Cursor)
+ }
+ })
+ }
+}
+
+// Home/End move within the current line, Home toggling between the first
+// non-blank column and column 0.
+func TestLineStartEndViaHomeEnd(t *testing.T) {
+ e := newEditorWith(" indented line", "second")
+ e.Cursor = Position{Row: 0, Col: 8}
e.HandleKey(tea.KeyMsg{Type: tea.KeyEnd})
- if e.Cursor != (Position{Row: 2, Col: 9}) {
- t.Errorf("End → %+v, want {2 9}", e.Cursor)
+ if e.Cursor != (Position{Row: 0, Col: len(" indented line")}) {
+ t.Errorf("End → %+v, want row 0 col %d (line end)", e.Cursor, len(" indented line"))
}
e.HandleKey(tea.KeyMsg{Type: tea.KeyHome})
- if e.Cursor != (Position{Row: 0, Col: 0}) {
- t.Errorf("Home → %+v, want {0 0}", e.Cursor)
+ if e.Cursor != (Position{Row: 0, Col: 2}) {
+ t.Errorf("Home → %+v, want row 0 col 2 (first non-blank)", e.Cursor)
+ }
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyHome})
+ if e.Cursor.Col != 0 {
+ t.Errorf("Home again → col %d, want 0 (toggle to column 0)", e.Cursor.Col)
}
}
-func TestLineStartEndViaCtrlUpDown(t *testing.T) {
- e := newEditorWith(" indented line")
- e.Cursor = Position{Row: 0, Col: 8}
- e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlDown})
- if e.Cursor.Col != len(" indented line") {
- t.Errorf("Ctrl+Down → col %d, want %d (line end)", e.Cursor.Col, len(" indented line"))
+// The key types the README's terminal config targets. Bubbletea's Key struct
+// carries no Cmd/Super modifier, so a Cmd+arrow only reaches glint as whichever
+// escape sequence the terminal is configured to emit; these are the four the
+// README tells users to map, named by the type bubbletea decodes them to.
+func TestCmdArrowTargetsAreBound(t *testing.T) {
+ for _, tc := range []struct {
+ key tea.KeyType
+ seq string // what the terminal must send
+ cmd string // the Cmd+arrow mapped to it
+ want Position
+ }{
+ {tea.KeyEnd, "\x1b[F", "cmd+right", Position{Row: 1, Col: 15}},
+ {tea.KeyHome, "\x1b[H", "cmd+left", Position{Row: 1, Col: 2}},
+ {tea.KeyCtrlEnd, "\x1b[1;5F", "cmd+down", Position{Row: 2, Col: 4}},
+ {tea.KeyCtrlHome, "\x1b[1;5H", "cmd+up", Position{Row: 0, Col: 0}},
+ } {
+ e := newEditorWith("first", " indented line", "last")
+ e.Cursor = Position{Row: 1, Col: 8}
+ e.HandleKey(tea.KeyMsg{Type: tc.key})
+ if e.Cursor != tc.want {
+ t.Errorf("%s (%q → %s) → %+v, want %+v", tc.cmd, tc.seq, tc.key, e.Cursor, tc.want)
+ }
}
- e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlUp})
- if e.Cursor.Col != 2 {
- t.Errorf("Ctrl+Up → col %d, want 2 (first non-blank)", e.Cursor.Col)
+}
+
+// The Shift variants extend a selection over the same spans their plain keys move.
+func TestShiftHomeEndSelectsWithinLine(t *testing.T) {
+ e := newEditorWith("hello world", "second")
+ e.Cursor = Position{Row: 0, Col: 6}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftEnd})
+ if got := e.SelectedText(); got != "world" {
+ t.Errorf("Shift+End selected %q, want world", got)
+ }
+ e.ClearSelection()
+ e.Cursor = Position{Row: 0, Col: 6}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftHome})
+ if got := e.SelectedText(); got != "hello " {
+ t.Errorf("Shift+Home selected %q, want %q", got, "hello ")
}
- e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlUp})
- if e.Cursor.Col != 0 {
- t.Errorf("Ctrl+Up again → col %d, want 0 (toggle to column 0)", e.Cursor.Col)
+}
+
+func TestCtrlShiftUpDownSelectsToDocEdges(t *testing.T) {
+ e := newEditorWith("first", "middle", "last")
+ e.Cursor = Position{Row: 1, Col: 0}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlShiftDown})
+ if got := e.SelectedText(); got != "middle\nlast" {
+ t.Errorf("Ctrl+Shift+Down selected %q, want %q", got, "middle\nlast")
+ }
+ e.ClearSelection()
+ e.Cursor = Position{Row: 1, Col: 0}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlShiftUp})
+ if got := e.SelectedText(); got != "first\n" {
+ t.Errorf("Ctrl+Shift+Up selected %q, want %q", got, "first\n")
}
}
internal/help/help.go +3 −2
@@ -44,8 +44,9 @@ Ctrl+F fuzzy file picker (prefix the query with / to full-text
search note contents; results open at the matching line)
Ctrl+G find in document (Enter/down next, Shift+Tab/up prev)
Ctrl+L go to line (type a number, Enter to jump)
- Home / End top / bottom of the document
- Ctrl+Up / Ctrl+Down line start (first non-blank, then column 0) / line end
+ Home / End line start (first non-blank, then column 0) / line end
+ Ctrl+Home / Ctrl+End top / bottom of the document (also Ctrl+Up / Ctrl+Down,
+ which macOS steals for Mission Control)
Ctrl+D today's daily note
Ctrl+N new note in the current directory
Ctrl+B new note in the inbox
internal/preview/frontmatter.go +128 −0
@@ -0,0 +1,128 @@
+package preview
+
+import "strings"
+
+// prop is one frontmatter key and its flattened display value.
+type prop struct {
+ key string
+ val string
+}
+
+// listSep joins the items of a YAML sequence into one display value.
+const listSep = " · "
+
+// splitFrontmatter peels a leading YAML frontmatter block (--- … ---) off the
+// document, returning its properties and the remaining markdown. A block that
+// doesn't start on the first line, or never closes, is left in the body: it's a
+// horizontal rule, not metadata.
+func splitFrontmatter(md string) ([]prop, string) {
+ if !strings.HasPrefix(md, "---\n") && !strings.HasPrefix(md, "---\r\n") {
+ return nil, md
+ }
+ lines := strings.Split(md[strings.IndexByte(md, '\n')+1:], "\n")
+ for i, ln := range lines {
+ if t := strings.TrimRight(ln, "\r"); t == "---" || t == "..." {
+ return parseProps(lines[:i]), strings.Join(lines[i+1:], "\n")
+ }
+ }
+ return nil, md
+}
+
+// parseProps reads the lines between the frontmatter fences. It handles the
+// shapes Obsidian writes — scalars, inline arrays, and indented "- item"
+// sequences — flattening each to a single line. Anything more exotic (nested
+// maps, block scalars) degrades to its raw text rather than erroring.
+func parseProps(lines []string) []prop {
+ var props []prop
+ var items []string
+ flush := func() {
+ if len(items) > 0 && len(props) > 0 {
+ props[len(props)-1].val = strings.Join(items, listSep)
+ }
+ items = nil
+ }
+ for _, raw := range lines {
+ ln := strings.TrimRight(raw, "\r")
+ t := strings.TrimSpace(ln)
+ if t == "" || strings.HasPrefix(t, "#") {
+ continue
+ }
+ // Indented or dashed lines belong to the property above.
+ if ln != t || strings.HasPrefix(t, "-") {
+ if strings.HasPrefix(t, "-") {
+ items = append(items, cleanScalar(strings.TrimSpace(t[1:])))
+ } else if len(props) > 0 {
+ items = append(items, cleanScalar(t))
+ }
+ continue
+ }
+ flush()
+ k, v, ok := strings.Cut(ln, ":")
+ if !ok {
+ continue
+ }
+ props = append(props, prop{strings.TrimSpace(k), cleanValue(strings.TrimSpace(v))})
+ }
+ flush()
+ return props
+}
+
+// cleanValue flattens one scalar or inline array ("[a, b]") to display text.
+func cleanValue(v string) string {
+ if len(v) >= 2 && strings.HasPrefix(v, "[") && strings.HasSuffix(v, "]") {
+ // A wikilink ("[[Note]]") is a scalar, not an array.
+ if !strings.HasPrefix(v, "[[") {
+ var out []string
+ for _, item := range splitItems(v[1 : len(v)-1]) {
+ if s := cleanScalar(item); s != "" {
+ out = append(out, s)
+ }
+ }
+ return strings.Join(out, listSep)
+ }
+ }
+ return cleanScalar(v)
+}
+
+// splitItems splits an inline array's contents on commas outside quotes.
+func splitItems(s string) []string {
+ var out []string
+ var quote rune
+ start := 0
+ for i, r := range s {
+ switch {
+ case quote != 0:
+ if r == quote {
+ quote = 0
+ }
+ case r == '"' || r == '\'':
+ quote = r
+ case r == ',':
+ out = append(out, s[start:i])
+ start = i + 1
+ }
+ }
+ return append(out, s[start:])
+}
+
+// cleanScalar trims a value and drops one layer of matching quotes.
+func cleanScalar(s string) string {
+ s = strings.TrimSpace(s)
+ if len(s) >= 2 {
+ if q := s[0]; (q == '"' || q == '\'') && s[len(s)-1] == q {
+ return s[1 : len(s)-1]
+ }
+ }
+ return s
+}
+
+// takeTitle pulls the "title" property out of props, so a doc titled in its
+// frontmatter uses that name for the heading instead of repeating it below.
+func takeTitle(props []prop) (string, []prop) {
+ for i, p := range props {
+ if p.key == "title" && p.val != "" {
+ return p.val, append(props[:i:i], props[i+1:]...)
+ }
+ }
+ return "", props
+}
internal/preview/frontmatter_test.go +193 −0
@@ -0,0 +1,193 @@
+package preview
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestSplitFrontmatter(t *testing.T) {
+ md := "---\ncreated: 2026-07-08\nstatus: \"🟢 In progress\"\ntags:\n - daily\n - tui\ncategories: [\"[[Projects]]\", Notes]\n---\n\n# Body\n\ntext\n"
+ props, body := splitFrontmatter(md)
+ want := []prop{
+ {"created", "2026-07-08"},
+ {"status", "🟢 In progress"},
+ {"tags", "daily · tui"},
+ {"categories", "[[Projects]] · Notes"},
+ }
+ if !reflect.DeepEqual(props, want) {
+ t.Errorf("props = %#v, want %#v", props, want)
+ }
+ if body != "\n# Body\n\ntext\n" {
+ t.Errorf("body = %q", body)
+ }
+}
+
+func TestSplitFrontmatterNone(t *testing.T) {
+ md := "# Body\n\ntext\n"
+ props, body := splitFrontmatter(md)
+ if len(props) != 0 || body != md {
+ t.Errorf("no frontmatter: props=%v body=%q", props, body)
+ }
+ // A "---" that isn't on line one is a horizontal rule, not frontmatter.
+ md2 := "intro\n\n---\nkey: value\n---\n"
+ props, body = splitFrontmatter(md2)
+ if len(props) != 0 || body != md2 {
+ t.Errorf("mid-document rule treated as frontmatter: props=%v", props)
+ }
+}
+
+func TestSplitFrontmatterUnterminated(t *testing.T) {
+ md := "---\nkey: value\n\n# Body\n"
+ props, body := splitFrontmatter(md)
+ if len(props) != 0 || body != md {
+ t.Errorf("unterminated block should be left alone: props=%v body=%q", props, body)
+ }
+}
+
+func TestSplitFrontmatterEmptyValues(t *testing.T) {
+ md := "---\naliases:\ntitle: Note\n---\nbody\n"
+ props, _ := splitFrontmatter(md)
+ want := []prop{{"aliases", ""}, {"title", "Note"}}
+ if !reflect.DeepEqual(props, want) {
+ t.Errorf("props = %#v, want %#v", props, want)
+ }
+}
+
+// A colon inside a value (a URL, a time) must not split the key twice.
+func TestSplitFrontmatterColonInValue(t *testing.T) {
+ md := "---\nsource: https://example.com/a\nat: 09:30\n---\nbody\n"
+ props, _ := splitFrontmatter(md)
+ want := []prop{{"source", "https://example.com/a"}, {"at", "09:30"}}
+ if !reflect.DeepEqual(props, want) {
+ t.Errorf("props = %#v, want %#v", props, want)
+ }
+}
+
+func TestHeaderTitleFromFrontmatter(t *testing.T) {
+ m := New("")
+ m.SetColors(testColors)
+ m.SetSize(60, 20)
+ m.SetTitle("filename-title")
+ if err := m.Render("---\ntitle: Frontmatter Title\ncreated: 2026-07-08\n---\n\nbody\n"); err != nil {
+ t.Fatal(err)
+ }
+ out := strings.Join(stripANSI(m.vp.View()), "\n")
+ if !strings.Contains(out, "Frontmatter Title") {
+ t.Errorf("frontmatter title not used as doc name:\n%s", out)
+ }
+ if strings.Contains(out, "filename-title") {
+ t.Errorf("filename should lose to frontmatter title:\n%s", out)
+ }
+ // The title key is consumed by the heading, not repeated as a property.
+ if strings.Contains(out, "title ") {
+ t.Errorf("title still listed as a property:\n%s", out)
+ }
+}
+
+func TestHeaderPropsAreTightAndRuleFree(t *testing.T) {
+ m := New("")
+ m.SetColors(testColors)
+ m.SetSize(60, 30)
+ m.SetTitle("My Note")
+ if err := m.Render("---\ncreated: 2026-07-08\nstatus: In progress\ntags:\n - daily\n---\n\nbody text\n"); err != nil {
+ t.Fatal(err)
+ }
+ lines := stripANSI(m.vp.View())
+ find := func(want string) int {
+ for i, ln := range lines {
+ if strings.HasPrefix(strings.TrimSpace(ln), want) {
+ return i
+ }
+ }
+ t.Fatalf("line starting %q not found in:\n%s", want, strings.Join(lines, "\n"))
+ return -1
+ }
+ title := find("My Note")
+ created := find("created")
+ // Doc name sits above the properties, with one blank line between.
+ if created != title+2 {
+ t.Errorf("properties start at %d, want %d (one blank line under the title)", created, title+2)
+ }
+ // Properties are consecutive: no blank lines between them.
+ if got := strings.TrimSpace(lines[created+1]); !strings.HasPrefix(got, "status") {
+ t.Errorf("line after created = %q, want status", got)
+ }
+ if got := strings.TrimSpace(lines[created+2]); !strings.HasPrefix(got, "tags") {
+ t.Errorf("line after status = %q, want tags", got)
+ }
+ // The --- fences never render as horizontal rules.
+ for _, ln := range lines {
+ if strings.HasPrefix(strings.TrimSpace(ln), "---") {
+ t.Errorf("frontmatter fence rendered as a rule:\n%s", strings.Join(lines, "\n"))
+ break
+ }
+ }
+ // Values are aligned in a column, keys padded to the widest key.
+ for _, ln := range lines[created : created+3] {
+ if !strings.Contains(ln, " 2026-07-08") && !strings.Contains(ln, " In progress") && !strings.Contains(ln, " daily") {
+ t.Errorf("property value not in an aligned column: %q", ln)
+ }
+ }
+}
+
+func TestHeaderTitleBarIsStyled(t *testing.T) {
+ m := New("")
+ m.SetColors(testColors)
+ m.SetSize(60, 20)
+ m.SetTitle("My Note")
+ if err := m.Render("body\n"); err != nil {
+ t.Fatal(err)
+ }
+ raw := m.vp.View()
+ line := strings.Split(raw, "\n")[0]
+ if !strings.Contains(line, "My Note") {
+ t.Fatalf("title not on the first line: %q", line)
+ }
+ // The heading color paints the bar background, with legible text on top.
+ if !strings.Contains(line, "48;2;209;77;65") {
+ t.Errorf("title bar missing the heading background: %q", line)
+ }
+ if !strings.Contains(line, "38;2;255;252;240") {
+ t.Errorf("title bar missing legible foreground: %q", line)
+ }
+ // The bar spans the full preview width, like a filled H1 block.
+ if w := len(stripANSI(line)[0]); w != 60 {
+ t.Errorf("title bar width = %d, want 60", w)
+ }
+}
+
+func TestHeaderAbsentWithoutTitleOrProps(t *testing.T) {
+ m := New("")
+ m.SetColors(testColors)
+ m.SetSize(60, 20)
+ if err := m.Render("# Heading\n\nbody\n"); err != nil {
+ t.Fatal(err)
+ }
+ if h := m.renderHeader("", nil); h != "" {
+ t.Errorf("renderHeader with nothing to show = %q", h)
+ }
+ // The document opens on glamour's own H1, not a doc-name bar above it.
+ lines := stripANSI(m.vp.View())
+ if got := strings.TrimSpace(lines[0]); got != "" {
+ t.Errorf("untitled doc grew a header: %q", got)
+ }
+ if got := strings.TrimSpace(lines[1]); got != "Heading" {
+ t.Errorf("body H1 should be the first thing rendered, got %q", got)
+ }
+}
+
+func TestHeaderWrapsLongValues(t *testing.T) {
+ m := New("")
+ m.SetColors(testColors)
+ m.SetSize(40, 20)
+ m.SetTitle("N")
+ if err := m.Render("---\ntags: alpha beta gamma delta epsilon zeta eta theta\n---\nbody\n"); err != nil {
+ t.Fatal(err)
+ }
+ for _, ln := range stripANSI(m.vp.View()) {
+ if w := len([]rune(ln)); w > 40 {
+ t.Errorf("line exceeds width 40 (%d): %q", w, ln)
+ }
+ }
+}
internal/preview/header.go +158 −0
@@ -0,0 +1,158 @@
+package preview
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// SGR escapes. The header writes them by hand rather than through lipgloss so
+// the colors survive a non-TTY renderer (tests, pipes) exactly like the rest of
+// this package's output, which glamour also emits unconditionally.
+const (
+ sgrReset = "\x1b[0m"
+ sgrBold = "\x1b[1m"
+ sgrItalic = "\x1b[3m"
+)
+
+// propIndent is the left inset of the properties block, and propGap the space
+// between the key column and the value column.
+const (
+ propIndent = " "
+ propGap = " "
+)
+
+// renderHeader draws the document header that sits above the glamour body: the
+// doc name as a full-width bar in the heading color (an H1 block that spans the
+// column), then the frontmatter properties as a tight, aligned, italic list —
+// dim keys, italic values, one line each, no rules and no blank lines between.
+// Returns "" when there is neither a name nor a property.
+func (m *Model) renderHeader(title string, props []prop) string {
+ if title == "" && len(props) == 0 {
+ return ""
+ }
+ width := m.width
+ if width < 1 {
+ width = 80
+ }
+ var b strings.Builder
+ if title != "" {
+ b.WriteString(m.titleBar(title, width))
+ b.WriteByte('\n')
+ }
+ if len(props) > 0 {
+ if title != "" {
+ b.WriteByte('\n') // one blank line under the name
+ }
+ b.WriteString(m.propList(props, width))
+ }
+ return b.String()
+}
+
+// titleBar renders the doc name as a bold, full-width filled block: the heading
+// color behind, a legible text color on top. Without a themed heading color it
+// falls back to bold text.
+func (m *Model) titleBar(title string, width int) string {
+ pad := width - lipgloss.Width(title) - 2 // one space each side
+ if pad < 0 {
+ title = truncate(title, width-2)
+ pad = 0
+ }
+ line := " " + title + " " + strings.Repeat(" ", pad)
+ open := sgrBold
+ if bg := hexToRGB(m.colors.Heading); bg != "" {
+ open += "\x1b[38;2;" + hexToRGB(legibleText(m.colors.Heading)) + ";48;2;" + bg + "m"
+ }
+ return open + line + sgrReset
+}
+
+// propList renders the aligned key/value rows. Keys are padded to the widest
+// key so the values form a column; long values wrap with a hanging indent that
+// keeps them inside that column.
+func (m *Model) propList(props []prop, width int) string {
+ keyw := 0
+ for _, p := range props {
+ if w := lipgloss.Width(p.key); w > keyw {
+ keyw = w
+ }
+ }
+ valLeft := len(propIndent) + keyw + len(propGap)
+ valWidth := max(width-valLeft, 8)
+ keyOpen, valOpen := m.propStyles()
+
+ var b strings.Builder
+ for _, p := range props {
+ b.WriteString(propIndent)
+ b.WriteString(keyOpen)
+ b.WriteString(p.key)
+ b.WriteString(strings.Repeat(" ", keyw-lipgloss.Width(p.key)))
+ b.WriteString(sgrReset)
+ b.WriteString(propGap)
+ for i, ln := range wrapWords(p.val, valWidth) {
+ if i > 0 {
+ b.WriteString(strings.Repeat(" ", valLeft))
+ }
+ b.WriteString(valOpen)
+ b.WriteString(ln)
+ b.WriteString(sgrReset)
+ b.WriteByte('\n')
+ }
+ if p.val == "" {
+ b.WriteByte('\n')
+ }
+ }
+ return b.String()
+}
+
+// propStyles returns the SGR prefixes for a property key (dim) and value
+// (italic), falling back to plain text when the theme colors are unset.
+func (m *Model) propStyles() (key, val string) {
+ if c := hexToRGB(m.colors.Muted); c != "" {
+ key = "\x1b[38;2;" + c + "m"
+ }
+ val = sgrItalic
+ if c := hexToRGB(m.colors.Text); c != "" {
+ val += "\x1b[38;2;" + c + "m"
+ }
+ return key, val
+}
+
+// wrapWords greedily wraps text to width, breaking on spaces. An empty string
+// yields no lines.
+func wrapWords(s string, width int) []string {
+ if s == "" {
+ return nil
+ }
+ var lines []string
+ cur := ""
+ for _, w := range strings.Fields(s) {
+ switch {
+ case cur == "":
+ cur = w
+ case lipgloss.Width(cur)+1+lipgloss.Width(w) <= width:
+ cur += " " + w
+ default:
+ lines = append(lines, cur)
+ cur = w
+ }
+ }
+ if cur != "" {
+ lines = append(lines, cur)
+ }
+ return lines
+}
+
+// truncate cuts s to at most width display cells, ending in an ellipsis.
+func truncate(s string, width int) string {
+ if width < 1 {
+ return ""
+ }
+ if lipgloss.Width(s) <= width {
+ return s
+ }
+ r := []rune(s)
+ for len(r) > 0 && lipgloss.Width(string(r))+1 > width {
+ r = r[:len(r)-1]
+ }
+ return string(r) + "…"
+}
internal/preview/preview.go +15 −3
@@ -173,12 +173,14 @@ Text string
Heading string
Code string
Link string
+ Muted string // frontmatter property keys
}
type Model struct {
vp viewport.Model
style string
colors Colors
+ title string
width int
height int
}
@@ -212,17 +214,27 @@
// SetStyle changes the glamour style used by the next Render.
func (m *Model) SetStyle(s string) { m.style = s }
-// Render runs markdown through Glamour and loads it into the viewport.
+// SetTitle sets the document name shown as the preview's heading bar — the
+// filename, which a frontmatter "title" property overrides at render time.
+func (m *Model) SetTitle(t string) { m.title = t }
+
+// Render peels the frontmatter off the document, runs the remaining markdown
+// through Glamour, and loads the doc-name bar and property block above it.
func (m *Model) Render(markdown string) error {
r, err := m.renderer()
if err != nil {
return err
}
- out, err := r.Render(markdown)
+ props, body := splitFrontmatter(markdown)
+ title, props := takeTitle(props)
+ if title == "" {
+ title = m.title
+ }
+ out, err := r.Render(body)
if err != nil {
return err
}
- m.vp.SetContent(out)
+ m.vp.SetContent(m.renderHeader(title, props) + out)
m.vp.GotoTop()
return nil
}
internal/preview/theme_test.go +1 −0
@@ -7,6 +7,7 @@ )
var testColors = Colors{
Background: "#100F0F", Text: "#CECDC3", Heading: "#D14D41", Code: "#879A39", Link: "#4385BE",
+ Muted: "#6F6E69",
}
func renderPreview(t *testing.T, md string) string {