▍ humdrum codex / glint v1.1.2
license AGPL-3.0

feat: filename header bar in editor mode

488500eeec2d673d4f28ff303095ffdefffd6444
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-30 19:13

parent d39ddc56

feat: filename header bar in editor mode

Basename (minus .md) as a heading-colored bar painted into the existing
top pad, so it costs no text rows. Pathless buffers show a muted
'Untitled'; a dirty buffer appends a bullet (TASK-046).

3 files changed

internal/app/app.go +7 −0
@@ -1071,7 +1071,14 @@ func (a *App) paintCanvas(body string) string {
 	bg := lipgloss.NewStyle().Background(a.theme.Background).Width(maxInt(a.width, 1))
 	lm := strings.Repeat(" ", a.leftMargin())
 	rows := make([]string, 0, a.height)
+	// The filename bar lives in the middle row of the top pad, so it costs no
+	// text rows: the canvas is still exactly height-1 rows.
+	header := a.headerBar()
 	for p := 0; p < a.topPad(); p++ {
+		if p == 1 && header != "" {
+			rows = append(rows, bg.Render(lm+header))
+			continue
+		}
 		rows = append(rows, bg.Render(""))
 	}
 	// A body that ends in "\n" (editor.View always does) would split into a
internal/app/header.go +65 −0
@@ -0,0 +1,65 @@
+package app
+
+import (
+	"path/filepath"
+	"strings"
+
+	"glint/internal/theme"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// headerText is the name shown in the editor's filename bar: the file's
+// basename without its extension, or "Untitled" for a buffer with no path. A
+// frontmatter "title" deliberately does not override it — edit mode shows what
+// is on disk. Unsaved changes append a bullet.
+func (a *App) headerText() string {
+	name := "Untitled"
+	if a.path != "" {
+		base := filepath.Base(a.path)
+		name = strings.TrimSuffix(base, filepath.Ext(base))
+	}
+	if a.editor.Dirty {
+		name += " •"
+	}
+	return name
+}
+
+// showHeader reports whether the filename bar belongs on screen: in the editor
+// and the overlays that keep it visible beneath them. The picker has its own
+// layout, help replaces the body, and preview draws its own title bar.
+func (a *App) showHeader() bool {
+	switch a.mode {
+	case ModeEditor, ModeFind, ModeGotoLine, ModeSpell:
+		return true
+	}
+	return false
+}
+
+// headerBar renders the filename as one content-column-wide row: a bold filled
+// block in the heading color for a named file, muted plain text for an unnamed
+// buffer so "unsaved and unnamed" reads at a glance. Returns "" when the header
+// does not belong on screen.
+func (a *App) headerBar() string {
+	if !a.showHeader() {
+		return ""
+	}
+	w := a.contentWidth()
+	if w < 3 {
+		return ""
+	}
+	label := truncate(" "+a.headerText()+" ", w)
+	if a.path == "" {
+		return lipgloss.NewStyle().
+			Foreground(a.theme.Muted).
+			Background(a.theme.Background).
+			Width(w).
+			Render(label)
+	}
+	return lipgloss.NewStyle().
+		Bold(true).
+		Foreground(lipgloss.Color(theme.LegibleText(string(a.theme.Heading)))).
+		Background(a.theme.Heading).
+		Width(w).
+		Render(label)
+}
internal/app/header_test.go +127 −0
@@ -0,0 +1,127 @@
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"glint/internal/theme"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestHeaderTextIsBasenameWithoutExtension(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/my-note.md"
+	if got := a.headerText(); got != "my-note" {
+		t.Errorf("headerText() = %q, want my-note", got)
+	}
+}
+
+func TestHeaderTextMarksDirty(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/my-note.md"
+	a.editor.Dirty = true
+	if got := a.headerText(); got != "my-note •" {
+		t.Errorf("headerText() = %q, want 'my-note •'", got)
+	}
+}
+
+func TestHeaderTextForPathlessBuffer(t *testing.T) {
+	a := newApp()
+	a.path = ""
+	if got := a.headerText(); got != "Untitled" {
+		t.Errorf("headerText() = %q, want Untitled", got)
+	}
+}
+
+func TestHeaderIgnoresFrontmatterTitle(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "on-disk.md")
+	os.WriteFile(p, []byte("---\ntitle: Something Else\n---\n\nbody"), 0o644)
+
+	a := newApp()
+	if err := a.Load(p); err != nil {
+		t.Fatal(err)
+	}
+	if got := a.headerText(); got != "on-disk" {
+		t.Errorf("headerText() = %q, want on-disk (edit mode shows the file, not the title)", got)
+	}
+}
+
+func TestHeaderBarAppearsInEditorView(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "visible.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Load(p)
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	if !strings.Contains(a.View(), "visible") {
+		t.Errorf("editor View() has no filename header:\n%s", a.View())
+	}
+}
+
+func TestHeaderCostsNoTextRows(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "rows.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Load(p)
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	gotW, gotH := a.editor.Width, a.editor.Height
+	if gotH != 20-1-canvasTopPad {
+		t.Errorf("editor height = %d, want %d (header must not steal a text row)", gotH, 20-1-canvasTopPad)
+	}
+	if gotW != a.contentWidth() {
+		t.Errorf("editor width = %d, want %d", gotW, a.contentWidth())
+	}
+}
+
+func TestViewIsExactlyHeightRows(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "rows2.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Load(p)
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 14})
+	if n := len(strings.Split(a.View(), "\n")); n != 14 {
+		t.Errorf("View() = %d lines, want 14 (canvas height-1 + status bar)", n)
+	}
+}
+
+func TestHeaderHiddenInPicker(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/hidden.md"
+	a.mode = ModePicker
+	if a.showHeader() {
+		t.Error("showHeader() = true in ModePicker, want false")
+	}
+}
+
+func TestHeaderHiddenInPreviewAndHelp(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/hidden.md"
+	for _, m := range []Mode{ModePreview, ModeHelp} {
+		a.mode = m
+		if a.showHeader() {
+			t.Errorf("showHeader() = true in mode %d, want false", m)
+		}
+	}
+}
+
+func TestHeaderShownInOverlayModes(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/shown.md"
+	for _, m := range []Mode{ModeEditor, ModeFind, ModeGotoLine, ModeSpell} {
+		a.mode = m
+		if !a.showHeader() {
+			t.Errorf("showHeader() = false in mode %d, want true", m)
+		}
+	}
+}