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