▍ humdrum codex / glint v1.0.2

feat: picker sorts by mtime, floats today's daily, themed list

88a983f55589e464b561808a5491cc5efd90a9da
humdrum <me@humdrum.me> · 2026-06-28 09:32

parent 6f9f840b

3 files changed

internal/app/app.go +4 −1
@@ -206,6 +206,9 @@ func (a *App) cycleTheme() (tea.Model, tea.Cmd) {
 	a.theme = theme.Next(a.theme.Name)
 	a.editor.SetTheme(a.theme)
 	a.preview.SetStyle(a.glamourStyle())
+	if a.picker != nil {
+		a.picker.SetTheme(a.theme)
+	}
 	a.status = "Theme: " + a.theme.Name
 	return a, nil
 }
@@ -236,7 +239,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)
+	p, err := picker.New(a.cfg.VaultDir, a.theme, a.cfg.DailyPath(time.Now()))
 	if err != nil {
 		a.status = "Picker failed: " + err.Error()
 		return a, nil
internal/picker/picker.go +86 −35
@@ -1,4 +1,6 @@
 // Package picker is a fuzzy file browser over a directory of markdown files.
+// It sorts by modified-date (newest first), floats today's daily note to the
+// top, and paints with the active theme.
 package picker
 
 import (
@@ -6,33 +8,55 @@ 	"io/fs"
 	"path/filepath"
 	"sort"
 	"strings"
+	"time"
+
+	"glint/internal/theme"
 
 	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 )
 
-// Model holds the query input and the current match list.
+// fileEntry is a markdown file plus its modification time.
+type fileEntry struct {
+	path string
+	mod  time.Time
+}
+
+// Model holds the query input, the file set, and the current match list.
 type Model struct {
-	input   textinput.Model
-	all     []string // absolute .md paths
-	root    string
-	matches []string
-	sel     int
-	width   int
-	height  int
+	input     textinput.Model
+	all       []fileEntry
+	root      string
+	todayPath string
+	matches   []string
+	sel       int
+	width     int
+	height    int
+	theme     theme.Theme
 }
 
-// New walks root for markdown files and returns a ready picker.
-func New(root string) (*Model, error) {
+// 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) {
 	files, err := walkMarkdown(root)
 	if err != nil {
 		return nil, err
 	}
 	ti := textinput.New()
+	ti.Prompt = "note › "
 	ti.Placeholder = "filter…"
 	ti.Focus()
-	m := &Model{input: ti, all: files, root: root, width: 80, height: 24}
+	m := &Model{
+		input:     ti,
+		all:       files,
+		root:      root,
+		todayPath: todayPath,
+		width:     80,
+		height:    24,
+		theme:     th,
+	}
 	m.recompute()
 	return m, nil
 }
@@ -43,6 +67,12 @@ 	m.width = w
 	m.height = h
 }
 
+// SetTheme repaints the picker with a new theme.
+func (m *Model) SetTheme(t theme.Theme) { m.theme = t }
+
+// Query returns the current filter text (used by the app's new-note shortcut).
+func (m *Model) Query() string { return m.input.Value() }
+
 // Update handles query edits and selection movement.
 func (m *Model) Update(msg tea.Msg) tea.Cmd {
 	if key, ok := msg.(tea.KeyMsg); ok {
@@ -65,36 +95,51 @@ 	m.recompute()
 	return cmd
 }
 
-// recompute filters and ranks all paths against the current query.
+// recompute refilters and reranks against the current query.
 func (m *Model) recompute() {
-	q := m.input.Value()
+	m.matches = rankEntries(m.all, m.input.Value(), m.todayPath)
+	if m.sel >= len(m.matches) {
+		m.sel = 0
+	}
+}
+
+// rankEntries filters all entries by the fuzzy query and orders the survivors.
+// Empty query: today's note first, then modified-date descending. Non-empty:
+// fuzzy score descending, then modified-date descending.
+func rankEntries(all []fileEntry, query, today string) []string {
 	type scored struct {
-		path  string
+		e     fileEntry
 		score int
 	}
 	var hits []scored
-	for _, p := range m.all {
-		label := m.label(p)
-		if s, ok := fuzzyMatch(q, label); ok {
-			hits = append(hits, scored{p, s})
+	for _, e := range all {
+		label := filepath.Base(e.path)
+		if s, ok := fuzzyMatch(query, label); ok {
+			hits = append(hits, scored{e, s})
 		}
 	}
 	sort.SliceStable(hits, func(i, j int) bool {
+		if query == "" {
+			ti := hits[i].e.path == today
+			tj := hits[j].e.path == today
+			if ti != tj {
+				return ti // today's note floats up
+			}
+			return hits[i].e.mod.After(hits[j].e.mod)
+		}
 		if hits[i].score != hits[j].score {
 			return hits[i].score > hits[j].score
 		}
-		return hits[i].path < hits[j].path
+		return hits[i].e.mod.After(hits[j].e.mod)
 	})
-	m.matches = m.matches[:0]
+	out := make([]string, 0, len(hits))
 	for _, h := range hits {
-		m.matches = append(m.matches, h.path)
-	}
-	if m.sel >= len(m.matches) {
-		m.sel = 0
+		out = append(out, h.e.path)
 	}
+	return out
 }
 
-// label is the vault-relative path shown to the user and matched against.
+// label is the vault-relative path shown to the user.
 func (m *Model) label(p string) string {
 	if rel, err := filepath.Rel(m.root, p); err == nil {
 		return rel
@@ -110,13 +155,14 @@ 	}
 	return m.matches[m.sel]
 }
 
-// View renders the query line and the match list.
+// View renders the query line and the match list, themed.
 func (m *Model) View() string {
 	var b strings.Builder
 	b.WriteString(m.input.View())
 	b.WriteByte('\n')
-	selStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a24")).Background(lipgloss.Color("#e0af68"))
-	plain := lipgloss.NewStyle().Foreground(lipgloss.Color("#c0caf5"))
+	selStyle := lipgloss.NewStyle().Foreground(m.theme.SelFg).Background(m.theme.SelBg)
+	plain := lipgloss.NewStyle().Foreground(m.theme.Text)
+	pointer := lipgloss.NewStyle().Foreground(m.theme.Pointer)
 	rows := m.height - 2
 	if rows < 1 {
 		rows = 1
@@ -124,7 +170,8 @@ 	}
 	for i := 0; i < len(m.matches) && i < rows; i++ {
 		label := m.label(m.matches[i])
 		if i == m.sel {
-			b.WriteString(selStyle.Render("> " + label))
+			b.WriteString(pointer.Render("▸ "))
+			b.WriteString(selStyle.Render(label))
 		} else {
 			b.WriteString(plain.Render("  " + label))
 		}
@@ -133,10 +180,9 @@ 	}
 	return b.String()
 }
 
-// walkMarkdown returns absolute paths of .md files under root, skipping any
-// directory whose name begins with a dot.
-func walkMarkdown(root string) ([]string, error) {
-	var out []string
+// walkMarkdown returns .md files (with mtimes) under root, skipping dot-dirs.
+func walkMarkdown(root string) ([]fileEntry, error) {
+	var out []fileEntry
 	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
 		if err != nil {
 			return nil // skip unreadable entries
@@ -149,9 +195,14 @@ 			return nil
 		}
 		if strings.EqualFold(filepath.Ext(d.Name()), ".md") {
 			abs, aerr := filepath.Abs(path)
-			if aerr == nil {
-				out = append(out, abs)
+			if aerr != nil {
+				return nil
+			}
+			var mod time.Time
+			if info, ierr := d.Info(); ierr == nil {
+				mod = info.ModTime()
 			}
+			out = append(out, fileEntry{path: abs, mod: mod})
 		}
 		return nil
 	})
internal/picker/picker_test.go +54 −1
@@ -5,6 +5,7 @@ 	"os"
 	"path/filepath"
 	"sort"
 	"testing"
+	"time"
 )
 
 func TestFuzzyMatchSubsequence(t *testing.T) {
@@ -45,9 +46,13 @@ 	os.WriteFile(filepath.Join(root, ".git", "c.md"), nil, 0o644)
 	os.MkdirAll(filepath.Join(root, "sub"), 0o755)
 	os.WriteFile(filepath.Join(root, "sub", "d.md"), nil, 0o644)
 
-	got, err := walkMarkdown(root)
+	entries, err := walkMarkdown(root)
 	if err != nil {
 		t.Fatal(err)
+	}
+	var got []string
+	for _, e := range entries {
+		got = append(got, e.path)
 	}
 	sort.Strings(got)
 	want := []string{
@@ -63,3 +68,51 @@ 			t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
 		}
 	}
 }
+
+func TestRankEntriesMtimeDescending(t *testing.T) {
+	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	all := []fileEntry{
+		{path: "/v/old.md", mod: base},
+		{path: "/v/new.md", mod: base.Add(48 * time.Hour)},
+		{path: "/v/mid.md", mod: base.Add(24 * time.Hour)},
+	}
+	got := rankEntries(all, "", "")
+	want := []string{"/v/new.md", "/v/mid.md", "/v/old.md"}
+	for i := range want {
+		if got[i] != want[i] {
+			t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
+		}
+	}
+}
+
+func TestRankEntriesTodayFloatsToTop(t *testing.T) {
+	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	all := []fileEntry{
+		{path: "/v/new.md", mod: base.Add(48 * time.Hour)},
+		{path: "/v/Daily/2026-06-28.md", mod: base}, // oldest, but today
+	}
+	got := rankEntries(all, "", "/v/Daily/2026-06-28.md")
+	if got[0] != "/v/Daily/2026-06-28.md" {
+		t.Errorf("today's note should float to top, got %q", got[0])
+	}
+}
+
+func TestRankEntriesQueryFiltersAndTieBreaksByMtime(t *testing.T) {
+	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	all := []fileEntry{
+		{path: "/v/alpha.md", mod: base},
+		{path: "/v/alpha-new.md", mod: base.Add(24 * time.Hour)},
+		{path: "/v/zzz.md", mod: base.Add(72 * time.Hour)},
+	}
+	got := rankEntries(all, "alpha", "")
+	// zzz filtered out; both "alpha" files present, newer first on equal score is
+	// not guaranteed (scores differ), but zzz must be absent.
+	for _, p := range got {
+		if p == "/v/zzz.md" {
+			t.Error("zzz.md should not match query 'alpha'")
+		}
+	}
+	if len(got) != 2 {
+		t.Fatalf("got %d matches, want 2", len(got))
+	}
+}