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