▍ humdrum codex / glint v1.0.2

feat: live glamour preview pane in the picker

f46e7c3e400d6fc3274087835060e880bf62833d
humdrum <me@humdrum.me> · 2026-06-28 09:41

parent 88a983f5

3 files changed

internal/app/app.go +2 −1
@@ -208,6 +208,7 @@ 	a.editor.SetTheme(a.theme)
 	a.preview.SetStyle(a.glamourStyle())
 	if a.picker != nil {
 		a.picker.SetTheme(a.theme)
+		a.picker.SetStyle(a.glamourStyle())
 	}
 	a.status = "Theme: " + a.theme.Name
 	return a, nil
@@ -239,7 +240,7 @@ }
 
 // openPicker builds a fresh picker over the vault and switches to it.
 func (a *App) openPicker() (tea.Model, tea.Cmd) {
-	p, err := picker.New(a.cfg.VaultDir, a.theme, a.cfg.DailyPath(time.Now()))
+	p, err := picker.New(a.cfg.VaultDir, a.theme, a.cfg.DailyPath(time.Now()), a.glamourStyle())
 	if err != nil {
 		a.status = "Picker failed: " + err.Error()
 		return a, nil
internal/picker/picker.go +66 −4
@@ -5,11 +5,13 @@ package picker
 
 import (
 	"io/fs"
+	"os"
 	"path/filepath"
 	"sort"
 	"strings"
 	"time"
 
+	"glint/internal/preview"
 	"glint/internal/theme"
 
 	"github.com/charmbracelet/bubbles/textinput"
@@ -34,12 +36,15 @@ 	sel       int
 	width     int
 	height    int
 	theme     theme.Theme
+	preview   *preview.Model
+	style     string
 }
 
 // New walks root for markdown files and returns a ready picker themed by th.
 // todayPath is the absolute path of today's daily note (floated to the top when
-// present); pass "" if there is none.
-func New(root string, th theme.Theme, todayPath string) (*Model, error) {
+// present); pass "" if there is none. glamourStyle is the name of the glamour
+// rendering style for the preview pane.
+func New(root string, th theme.Theme, todayPath, glamourStyle string) (*Model, error) {
 	files, err := walkMarkdown(root)
 	if err != nil {
 		return nil, err
@@ -56,20 +61,30 @@ 		todayPath: todayPath,
 		width:     80,
 		height:    24,
 		theme:     th,
+		preview:   preview.New(glamourStyle),
+		style:     glamourStyle,
 	}
 	m.recompute()
 	return m, nil
 }
 
-// SetSize records dimensions.
+// SetSize records dimensions and sizes the preview pane.
 func (m *Model) SetSize(w, h int) {
 	m.width = w
 	m.height = h
+	m.preview.SetSize(m.previewWidth(), h-2)
 }
 
 // SetTheme repaints the picker with a new theme.
 func (m *Model) SetTheme(t theme.Theme) { m.theme = t }
 
+// SetStyle changes the preview's glamour style.
+func (m *Model) SetStyle(s string) {
+	m.style = s
+	m.preview.SetStyle(s)
+	m.renderPreview()
+}
+
 // Query returns the current filter text (used by the app's new-note shortcut).
 func (m *Model) Query() string { return m.input.Value() }
 
@@ -80,11 +95,13 @@ 		switch key.Type {
 		case tea.KeyUp:
 			if m.sel > 0 {
 				m.sel--
+				m.renderPreview()
 			}
 			return nil
 		case tea.KeyDown:
 			if m.sel < len(m.matches)-1 {
 				m.sel++
+				m.renderPreview()
 			}
 			return nil
 		}
@@ -101,6 +118,7 @@ 	m.matches = rankEntries(m.all, m.input.Value(), m.todayPath)
 	if m.sel >= len(m.matches) {
 		m.sel = 0
 	}
+	m.renderPreview()
 }
 
 // rankEntries filters all entries by the fuzzy query and orders the survivors.
@@ -155,8 +173,52 @@ 	}
 	return m.matches[m.sel]
 }
 
-// View renders the query line and the match list, themed.
+// previewWidth is the column width of the preview pane (~60%), or 0 when the
+// terminal is too narrow to split.
+func (m *Model) previewWidth() int {
+	if m.width < 60 {
+		return 0
+	}
+	return m.width*6/10 - 2 // minus border
+}
+
+// renderPreview loads the highlighted file into the preview viewport.
+func (m *Model) renderPreview() {
+	if m.previewWidth() <= 0 {
+		return
+	}
+	sel := m.Selected()
+	if sel == "" {
+		_ = m.preview.Render("")
+		return
+	}
+	data, err := os.ReadFile(sel)
+	if err != nil {
+		_ = m.preview.Render("_could not read file_")
+		return
+	}
+	_ = m.preview.Render(string(data))
+}
+
+// View renders the query + themed match list on the left and a glamour preview
+// of the highlighted file on the right (when the terminal is wide enough).
 func (m *Model) View() string {
+	left := m.listView()
+	if m.previewWidth() <= 0 {
+		return left
+	}
+	border := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(m.theme.Muted).
+		Width(m.previewWidth()).
+		Height(m.height - 2)
+	right := border.Render(m.preview.View())
+	listCol := lipgloss.NewStyle().Width(m.width - m.previewWidth() - 3).Render(left)
+	return lipgloss.JoinHorizontal(lipgloss.Top, listCol, right)
+}
+
+// listView is the query line plus the themed match list.
+func (m *Model) listView() string {
 	var b strings.Builder
 	b.WriteString(m.input.View())
 	b.WriteByte('\n')
internal/picker/picker_test.go +20 −0
@@ -4,8 +4,11 @@ import (
 	"os"
 	"path/filepath"
 	"sort"
+	"strings"
 	"testing"
 	"time"
+
+	"glint/internal/theme"
 )
 
 func TestFuzzyMatchSubsequence(t *testing.T) {
@@ -116,3 +119,20 @@ 	if len(got) != 2 {
 		t.Fatalf("got %d matches, want 2", len(got))
 	}
 }
+
+func TestPickerPreviewRendersSelectedFile(t *testing.T) {
+	root := t.TempDir()
+	p := filepath.Join(root, "note.md")
+	os.WriteFile(p, []byte("# Heading\n\nbody text"), 0o644)
+
+	m, err := New(root, theme.FlexokiDark(), "", "dark")
+	if err != nil {
+		t.Fatal(err)
+	}
+	m.SetSize(120, 30)
+	m.recompute() // selects note.md
+	view := m.View()
+	if !strings.Contains(view, "Heading") {
+		t.Errorf("preview pane should render the selected file's heading; view:\n%s", view)
+	}
+}